# easeo Documentation
> Full documentation for easeo: deterministic SEO payload generation
> Source: https://easeo.emiliano-go.com
> Pages: 51
========================================================================
PAGE: https://easeo.emiliano-go.com/about/changelog/
========================================================================
# Changelog { #changelog }
## 0.1.0
- Rust core with full SEO payload generation
- Python bindings via PyO3
- JavaScript/TypeScript bindings via napi-rs
- URL normalization and tracking parameter removal
- JSON-LD schema generation
- SEO contract system
- Deterministic output with SHA-256 hashing
- Config-scoped hooks and schema registries
- Framework integrations for Next.js, Astro, Vite, Nuxt, SvelteKit, React,
FastAPI, Django, Flask, and Zensical
========================================================================
PAGE: https://easeo.emiliano-go.com/about/comparison/
========================================================================
# Comparison: manual vs easeo { #comparison }
| Task | Manual | easeo |
|---|---|---|
| Canonical URL | Construct by hand | `build_seo_payload(entity, path, config)` |
| Open Graph tags | 10+ `` tags | `payload.og` or `payload.render_html()` |
| Twitter Cards | 6+ `` tags | `payload.twitter` or `payload.render_html()` |
| JSON-LD schema | Hand-written schema.org JSON | Auto-generated, extensible via registry |
| BreadcrumbList | Manual JSON-LD | `Breadcrumb(name, url)` auto-generates |
| URL normalization | HTTPS, slash, case logic | `URLPolicy` |
| HTML excerpt | Strip tags, decode, truncate | Built-in `body_html` snippet |
| Validation | Manual audit of lengths | `validate_payload()` / `emit_warnings` |
| HTML rendering | A template per tag | `payload.render_html()` |
| Content ETag | Manual hashing | `payload.etag()` |
| Testing | Manual fixtures | Deterministic: `payload == expected_dict` |
| Custom fields | Edit every template | Config-scoped `HookRegistry` |
## Why determinism matters
```python
p1 = build_seo_payload(entity, path, config)
p2 = build_seo_payload(entity, path, config)
assert p1 == p2 # always True
```
Most SEO tooling produces different output for identical input: timestamps,
cache busters, unstable dict ordering. easeo does none of that, so SEO becomes
a build artifact you can commit, diff, cache, and validate in CI.
========================================================================
PAGE: https://easeo.emiliano-go.com/about/contributing/
========================================================================
# Contributing { #contributing }
## Repository layout { #layout }
```text
easeo/
├── Cargo.toml # Rust workspace
├── pyproject.toml # maturin / Python package
├── crates/
│ ├── easeo-core/ # all logic
│ ├── easeo-python/ # PyO3 bindings
│ └── easeo-node/ # napi-rs bindings
├── packages/core/ # @easeo/core wrapper
├── integrations/ # JS framework integrations
├── python/easeo/ # Python package, adapters, contrib
├── tests/ # Python, JavaScript, conformance
├── fixtures/ # shared fixtures
├── schemas/ # JSON schemas
└── docs/ # this documentation
├── overrides/ # Zensical theme overrides (main.html, partials)
└── stylesheets/extra.css # theme CSS (accent, mobile drawer, tab dropdowns)
```
## Build and test { #build }
```bash
# Rust
cargo test --workspace
cargo fmt --all: --check
cargo clippy --workspace: -D warnings
# Python
maturin develop -m crates/easeo-python/Cargo.toml
pytest tests/python/
# JavaScript
cd packages/core
napi build --platform --release --manifest-path ../../crates/easeo-node/Cargo.toml
cp ../../crates/easeo-node/*.node .
cd ../..
node --test tests/javascript/*.cjs
# Cross-language conformance
python tests/conformance/test_conformance.py
```
## Rules of the codebase { #rules }
* **All logic lives in Rust.** The Python and JavaScript packages are bindings
and thin ergonomics wrappers. Do not duplicate resolution logic in a binding.
* **Determinism is non-negotiable.** No timestamps, randomness, environment
reads, or unordered maps in output. Use `BTreeMap` for anything that
serializes.
* **Cross-language parity.** A change to the Python API needs the JavaScript
equivalent, and a conformance test where output could differ.
* **Escaping happens in the core.** HTML and JSON-LD escaping is centralized so
every binding is safe.
## Documentation { #docs }
Docs live in `docs/` and build with Zensical. The site uses the easeo
`easeo.contrib.zensical` extension to generate per-page SEO tags, so easeo
must be importable by the same interpreter that runs Zensical.
[`uv`](https://docs.astral.sh/uv/) handles this in one command. It builds the
Rust extension from `crates/easeo-python/` into a local `.venv` and runs
Zensical with the `dev` dependency group, which includes `zensical` and
`markdown`:
```bash
uv run zensical serve # live preview on http://localhost:8000
uv run zensical build # writes site/
uv run python scripts/generate_llms_full.py # regenerate docs/llms-full.txt
```
If you prefer a manual environment, install the extra and run Zensical
directly:
```bash
pip install -e ".[zensical]"
zensical serve
```
To reproduce the exact artifact that CI and Cloudflare Pages deploy (editable
easeo, regenerated `llms-full.txt`, then the build), use the build script:
```bash
bash scripts/build_docs.sh
```
See [Deploying the Docs](deploying-docs.md#deploying-the-docs) for the
Cloudflare Pages and GitHub Pages settings.
Keep prose free of em dashes and double-hyphen separators; use commas, colons,
parentheses, or semicolons.
## Adding a framework integration { #integration }
1. Create a directory under `integrations/` with `index.js`, `index.d.ts`, and
`package.json`.
2. Support both default and named exports.
3. Call `@easeo/core` for all payload building. Never re-implement logic.
4. Add a test under `tests/javascript/`.
5. Add a page under `docs/integrations/` and a nav entry in `zensical.toml`.
========================================================================
PAGE: https://easeo.emiliano-go.com/about/deploying-docs/
========================================================================
# Deploying the Docs { #deploying-the-docs }
The documentation site dogfoods easeo: `easeo.contrib.zensical` generates the
per-page SEO tags, so easeo must be installed from the local source tree for
the site to have a real head. The build installs easeo **editable**, which
compiles the Rust extension and uses the working tree rather than the released
package.
## One command { #one-command }
`scripts/build_docs.sh` does the whole thing: it creates a virtual
environment, installs the build and docs dependencies, installs easeo
editable, regenerates `llms-full.txt`, and runs the Zensical build.
```bash
bash scripts/build_docs.sh
```
The output is written to `site/`.
Under the hood it runs the equivalent of:
```bash
python -m venv .docs-venv
source .docs-venv/bin/activate
pip install maturin zensical "markdown>=3.5"
pip install -e . --no-build-isolation
python scripts/generate_llms_full.py
zensical build
```
`pip install -e .` uses the `[tool.maturin]` `manifest-path` in
`pyproject.toml`, so it builds `crates/easeo-python/` and not the workspace
root.
## Requirements { #requirements }
* Python 3.10 or newer (pinned to 3.12 in `.python-version`).
* A Rust toolchain (stable). The script installs one with `rustup` when `cargo`
is missing, so minimal CI images work without a custom build image.
## Cloudflare Pages { #cloudflare-pages }
Connect the repository and set:
| Setting | Value |
|---|---|
| Framework preset | None |
| Build command | `bash scripts/build_docs.sh` |
| Build output directory | `site` |
| Root directory | `/` |
Environment variables:
| Variable | Value |
|---|---|
| `PYTHON_VERSION` | `3.12` |
| `DOCS_VENV_DIR` | `.docs-venv` (optional, this is the default) |
Cloudflare runs the build in a container that already has Python and pip. The
script creates its own virtualenv and installs Rust with `rustup` when it is
missing, so no global install and no `--break-system-packages` are needed.
## GitHub Pages { #github-pages }
The repository also ships a GitHub Pages workflow at
`.github/workflows/docs.yml`. It installs easeo with the `zensical` extra and
runs `zensical build`. Enable Pages with the GitHub Actions source in the
repository settings.
## Custom domain { #domain }
`docs/CNAME` contains the custom domain, so it is copied into `site/` at build
time:
```text
easeo.emiliano-go.com
```
Point the domain at the Pages project and keep `site_url` in `zensical.toml`
in sync, because the URL feeds canonical tags and the sitemap.
## Post-build SEO checklist { #checklist }
After a build, confirm:
* Every page has exactly one `
`.
* Every page has `` with the production URL.
* Every page has a real `` from front matter.
* Every page has Open Graph and Twitter tags and a JSON-LD block.
* `site/robots.txt` points at the easeo sitemap.
* `site/sitemap.xml` lists the production URLs.
========================================================================
PAGE: https://easeo.emiliano-go.com/about/why-easeo/
========================================================================
# Why easeo { #why-easeo }
Most SEO libraries do too much. They score content, analyze keywords, rewrite
descriptions, and pull in a browser engine. easeo does one thing: **generate
deterministic SEO metadata from content entities**.
## The problem with generated metadata { #problem }
SEO metadata is usually assembled ad hoc: a title here, an Open Graph block
there, a JSON-LD template somewhere else. It drifts. Nothing tests it. When a
deploy changes a canonical URL, no one notices until rankings move.
The root cause is that the output is not treated like code. It has no
deterministic contract, so it cannot be snapshotted or diffed.
## The easeo answer { #answer }
Turn the metadata into a pure function with a stable output.
```text
SEOEntity + route + SEOConfig -> SEOPayload
```
Same inputs, same bytes, every time. No timestamps, no randomness, no
environment reads, no hidden I/O. That makes the output:
- **snapshot testable**: assert against a committed fixture
- **hashable**: generate stable ETags
- **cacheable**: memoize without invalidation logic
- **diffable**: compare staging and production
- **CI-validatable**: commit SEO intent as a contract
## Why Rust { #rust }
The core is Rust so the same behavior ships to Python and JavaScript, not two
implementations that drift apart. The bindings are thin; all logic lives in one
place. Cross-language conformance tests assert that Python and Rust produce
byte-identical output.
## What it is not { #not }
- Not a crawler
- Not a scorer
- Not a keyword tool
- Not a browser automation framework
- Not an analytics platform
## Design principles { #principles }
1. **Deterministic**: same input, same output.
2. **Pure**: no network, no I/O, no randomness, no environment reads.
3. **Framework-agnostic**: the core knows nothing about React or Django.
4. **Minimal surface**: one primary function.
5. **Contract-first**: SEO intent is machine-readable and testable.
6. **Zero ceremony**: adapters are plug-and-play.
========================================================================
PAGE: https://easeo.emiliano-go.com/concepts/determinism/
========================================================================
# Determinism { #determinism }
Identical inputs always produce identical outputs. Always.
=== "Python"
```python
p1 = build_seo_payload(entity, "/blog/post", config)
p2 = build_seo_payload(entity, "/blog/post", config)
assert p1 == p2
```
=== "JavaScript"
```js
const p1 = buildSeoPayload(entity, "/blog/post", config);
const p2 = buildSeoPayload(entity, "/blog/post", config);
console.assert(p1.equals(p2));
```
## What is forbidden in the output { #forbidden }
* Current timestamp
* Random UUID
* Unordered serialization
* Environment-dependent values
* Hash maps in place of ordered maps
Mapping output uses `BTreeMap`, so key order is sorted and stable. JSON-LD
objects, Open Graph, and the canonical dict all serialize identically across
runs.
## What this enables { #enables }
| Capability | How it works |
|---|---|
| Snapshot testing | Commit expected payloads and assert equality in tests |
| CI validation | A changed payload fails the build instead of shipping |
| Caching | `@lru_cache` on `build_seo_payload` is safe forever |
| Content-addressed artifacts | `payload.hash()` is stable across machines |
| Deployment diffs | Compare staging and production payloads to find drift |
## Equality and hashing { #equality }
Python payloads compare against other payloads and against plain dicts:
```python
assert payload == other_payload
assert payload == payload.to_dict()
```
Both languages expose a stable SHA-256 hash and an HTTP ETag:
=== "Python"
```python
payload.hash() # 64 hex characters
payload.etag() # '""'
```
=== "JavaScript"
```js
payload.hash();
payload.etag();
```
## Hooks and determinism { #hooks }
easeo allows post-processing through config-scoped hooks. Because the hooks
registry is part of the `SEOConfig`, it is an ordinary input: the same config
produces the same output every time. There is no global mutable registry, so
two configs in the same process cannot interfere.
!!! note "Determinism is a property of your hooks too"
A hook that reads the clock or a random source breaks determinism for the
config that carries it. Keep hooks pure.
## Recap { #recap }
* Same inputs, same bytes, everywhere.
* Ordered maps and no ambient state are what make it true.
* This is the foundation for snapshot testing, caching, and CI diffs.
========================================================================
PAGE: https://easeo.emiliano-go.com/concepts/entity-model/
========================================================================
# Entity Model { #entity-model }
An `SEOEntity` is the content you already have, reshaped into the fields easeo
knows how to use. Only `entity_type` is required; every other field is
optional.
## All fields { #fields }
| Field | Type | Feeds |
|---|---|---|
| `entity_type` | `str` (required) | Meta plus schema selection |
| `title` | `str \| None` | Title, `og:title`, schema |
| `excerpt` | `str \| None` | Description, `og:description` |
| `body_html` | `str \| None` | Description snippet when no excerpt |
| `slug` | `str \| None` | Metadata only, not emitted |
| `status` | `str \| None` | Robots (`"published"` allows indexing) |
| `featured_image` | `SEOImage \| str \| None` | `og:image` and schema image |
| `published_at` | `str \| None` | Schema `datePublished` |
| `updated_at` | `str \| None` | Schema `dateModified` |
| `author_name` | `str \| None` | Schema author |
| `breadcrumbs` | `list[Breadcrumb] \| None` | `BreadcrumbList` JSON-LD |
| `sku` | `str \| None` | Product schema |
| `price` | `str \| None` | Product schema |
| `price_currency` | `str \| None` | Product schema |
| `availability` | `str \| None` | Product schema |
| `same_as` | `list[str] \| None` | Organization `sameAs` |
| `address` | `str \| None` | LocalBusiness address |
| `faq_items` | `list[FAQItem] \| None` | `FAQPage` schema |
## Entity types { #types }
The `entity_type` drives two things: the Open Graph type and the default
schema mapping.
| Entity type | OG type | Schema |
|---|---|---|
| `home` | `website` | `WebPage` |
| `post` | `article` | `Article` |
| `page` | `website` | `WebPage` |
| `video` | `article` | `VideoObject` |
| `taxonomy` | `website` | `CollectionPage` |
| `search` | `website` | `SearchResultsPage` |
| `product` | `website` | `Product` |
| `organization` | `website` | `Organization` |
| `local_business` | `website` | `LocalBusiness` |
| `faq` | `website` | `FAQPage` |
| `other` | `website` | none |
Override the mapping with `schema_type_map` on the config, or replace a single
page's schema with `SEOOverrides.schema_jsonld`.
## Building an entity { #building }
Three equivalent ways:
=== "Constructor"
```python
from easeo import SEOEntity
entity = SEOEntity(
entity_type="post",
title="Hello",
excerpt="A post.",
)
```
=== "Builder"
```python
from easeo import SEOEntityBuilder
entity = (
SEOEntityBuilder("post")
.title("Hello")
.excerpt("A post.")
.breadcrumb("Home", "/")
.build()
)
```
=== "Factory"
```python
from easeo import from_blog_post
entity = from_blog_post(title="Hello", body_html="
A post.
")
```
## Normalization { #normalization }
Optional string fields are stripped; empty strings become `None`. Lists such
as `same_as` are deduplicated. This keeps the output stable regardless of
whitespace in your source data.
## Recap { #recap }
* `entity_type` selects the OG type and schema.
* Most fields feed more than one output target.
* Constructor, builder, and factories are interchangeable.
========================================================================
PAGE: https://easeo.emiliano-go.com/concepts/fallback-chains/
========================================================================
# Fallback Chains { #fallback-chains }
Every field resolves through a priority chain; the first non-empty value wins.
Set site-wide defaults in `SEOConfig`, override per entity in `SEOEntity`, and
fine-tune per page with `SEOOverrides`.
## General precedence
1. **`SEOOverrides`**: per-call overrides (highest)
2. **`SEOEntity`**: content entity fields
3. **`SEOConfig`**: site-wide defaults
4. **Hardcoded defaults**: library fallbacks (lowest)
## title
1. `SEOOverrides.meta_title`
2. `SEOEntity.title`
3. `"Untitled"`
The config `title_template` is then applied unless `skip_title_template=True`.
```python
config = SEOConfig(..., title_template="{title} - My Site")
# "My Post" -> "My Post - My Site"
```
## description
1. `SEOOverrides.meta_description`
2. `SEOEntity.excerpt`
3. Auto snippet from `SEOEntity.body_html` (max 160 chars)
4. `""`
The body snippet strips scripts and styles, normalizes whitespace, and
truncates on a character boundary with an ellipsis.
## canonical
1. `SEOOverrides.canonical_url`
2. Normalized route path (full URL normalization pipeline)
## robots
1. `SEOOverrides.robots`
2. Entity-derived default:
- `entity_type == "search"` → `config.search_robots` (default `noindex,follow`)
- `entity.status == "published"` → `index,follow`
- otherwise → `config.default_robots` (default `index,follow`)
## Open Graph
| Field | Chain |
|---|---|
| `og:title` | `SEOOverrides.og_title` > resolved title |
| `og:description` | `SEOOverrides.og_description` > resolved description |
| `og:image` | `SEOOverrides.og_image` > `SEOEntity.featured_image` > `SEOConfig.default_og_image` |
The resolved image cascades to `twitter:image`.
## Twitter
| Field | Chain |
|---|---|
| `twitter:card` | `SEOOverrides.twitter_card` > `"summary_large_image"` |
| `twitter:title` | `SEOOverrides.twitter_title` > resolved `og:title` |
| `twitter:description` | `SEOOverrides.twitter_description` > resolved `og:description` |
| `twitter:image` | `SEOOverrides.twitter_image` > resolved `og:image` |
## schema_jsonld
1. `SEOOverrides.omit_schema` → `None`
2. `SEOOverrides.schema_jsonld` (normalized to a list when needed)
3. **`SchemaRegistry` generator** matching the resolved `@type` (Python/JS)
4. Auto-generated schema (when `config.auto_generate_schema`)
Breadcrumbs from `entity.breadcrumbs` are always appended as a
`BreadcrumbList`, and hooks run last over the assembled payload.
## Summary table
| Field | Chain |
|---|---|
| title | Override > Entity > `"Untitled"` + template |
| description | Override > Excerpt > Body snippet > `""` |
| canonical | Override > Normalized route |
| robots | Override > Entity status default |
| og:title | Override > Resolved title |
| og:description | Override > Resolved description |
| og:image | Override > Entity image > Config default |
| twitter:card | Override > `"summary_large_image"` |
| twitter:image | Override > resolved og:image |
| schema_jsonld | Override > Registry > auto-generated + breadcrumbs |
========================================================================
PAGE: https://easeo.emiliano-go.com/concepts/
========================================================================
# Concepts { #concepts }
This track explains how easeo is put together and why. Read it once and the
API becomes predictable: there are no hidden states, no environment reads, and
no surprises in the output.
## Pages { #pages }
* [Determinism](determinism.md#determinism): the core guarantee and what it
enables.
* [Entity Model](entity-model.md#entity-model): what a content entity is and
which fields feed which output.
* [Payload Model](payload-model.md#payload-model): the shape of the output and
the exact tag order.
* [Fallback Chains](fallback-chains.md#fallback-chains): how every field
resolves.
* [URL Normalization](url-normalization.md#url-normalization): the canonical
URL pipeline.
* [JSON-LD Schemas](schemas.md#schemas): the built-in schema types and how to
extend them.
* [Validation](validation.md#validation): the built-in best-practice checks.
## The one-sentence model { #model }
`build_seo_payload` is a pure function:
```text
SEOEntity + route + SEOConfig (+ SEOOverrides) -> SEOPayload
```
Everything else in easeo is either a value type that feeds that function or a
convenience wrapper around it.
========================================================================
PAGE: https://easeo.emiliano-go.com/concepts/payload-model/
========================================================================
# Payload Model { #payload-model }
An `SEOPayload` is the single output of `build_seo_payload`. It is structured,
hashable, renderable, and serializable.
## Structure { #structure }
| Field | Type | Description |
|---|---|---|
| `title` | `str` | Resolved title, after the template |
| `description` | `str` | Resolved description |
| `canonical` | `str` | Fully normalized canonical URL |
| `robots` | `str` | Robots meta content |
| `og` / `openGraph` | `OGPayload` | Open Graph fields |
| `twitter` | `TwitterPayload` | Twitter Card fields |
| `schema_jsonld` / `schemaJsonLd` | `dict \| list \| None` | JSON-LD |
## Methods { #methods }
| Purpose | Python | JavaScript |
|---|---|---|
| Full head | `render_html()` | `renderHtml()` |
| Open Graph only | `render_opengraph()` | `renderOpengraph()` |
| Twitter only | `render_twitter()` | `renderTwitter()` |
| JSON-LD only | `render_jsonld()` | `renderJsonld()` |
| Canonical dict | `to_dict()` | `toDict()` |
| CamelCase object | - | `toObject()` |
| JSON string | `to_json()` | `toJSONString()` / `toString()` |
| Hash | `hash()` | `hash()` |
| ETag | `etag()` | `etag()` |
## Dict access { #dict-access }
Python payloads are dict-compatible, which makes them easy to drop into
templates and tests:
```python
payload["title"]
payload.get("title", "fallback")
"title" in payload
list(payload)
len(payload)
payload == payload.to_dict()
```
JavaScript payloads expose the equivalents:
```js
payload.get("title");
payload.has("title");
payload.equals(other);
```
## The camelCase view { #camelcase }
In JavaScript, `toObject()` returns camelCase keys (`openGraph`,
`schemaJsonLd`) and is what `JSON.stringify` uses. `toDict()` and
`toJSONString()` return the canonical snake_case wire format shared with
Python, Rust, and the published JSON schemas.
```js
JSON.stringify(payload);
// {"title":...,"openGraph":{...},"schemaJsonLd":{...}}
payload.toDict();
// {"title":...,"og":{...},"schema_jsonld":{...}}
```
## Render order { #render-order }
`render_html()` emits tags in a fixed order:
1. ``
2. `` when a description exists
3. ``
4. ``
5. Open Graph tags
6. Twitter Card tags
7. JSON-LD `
```
## React { #react }
```tsx
import { EaseoHead } from "@easeo/react";
```
The component renders nothing; it keeps `document.head` in sync on the client.
For SSR, put `payload.renderHtml()` into your template.
## FastAPI { #fastapi }
```python
from easeo.adapters.fastapi import EaseoSEO
seo = EaseoSEO(config)
@app.get("/products/{slug}")
def product(slug: str):
return seo.for_entity(product, f"/products/{slug}")
```
## Flask { #flask }
```python
from easeo.adapters.flask import Easeo
easeo = Easeo(app, config)
```
Then in a template: `{% raw %}{{ easeo_head(entity, request.path) }}{% endraw %}`.
## Django { #django }
```python
# settings.py
EASEO = {
"canonical_host": "example.com",
"public_base_url": "https://example.com",
"site_name": "Example",
}
```
```django
{% load easeo_tags %}
{% easeo_head entity request.path %}
```
## Recap { #recap }
* Every JS integration supports default and named imports.
* Python adapters are lazy and installed as extras.
* Route and config are the two inputs every adapter needs.
========================================================================
PAGE: https://easeo.emiliano-go.com/guides/hooks/
========================================================================
# Hooks { #hooks }
Hooks post-process the payload after it is built. Use them to add a field to
every page, rewrite a description per section, or inject site-wide metadata.
Hooks are **config-scoped**. They live on the `SEOConfig` that carries them,
so `build_seo_payload` stays a pure function of its inputs and two configs in
the same process cannot interfere.
## Registering a hook { #register }
=== "Python"
```python
from easeo import HookRegistry, SEOConfig
hooks = HookRegistry()
@hooks.hook("post_process")
def add_generator(payload, entity, config):
payload["generator"] = "easeo"
return payload
config = SEOConfig(
canonical_host="example.com",
public_base_url="https://example.com",
hooks=hooks,
)
```
=== "JavaScript"
```js
const { HookRegistry } = require("@easeo/core");
const hooks = new HookRegistry();
hooks.register("post_process", (payload, entity, config) => {
payload.generator = "easeo";
return payload;
});
const config = {
canonicalHost: "example.com",
publicBaseUrl: "https://example.com",
hooks,
};
```
## The hook signature { #signature }
A hook receives three arguments and must return the payload:
```text
hook(payload, entity, config) -> payload
```
* `payload` is a plain dict in the canonical snake_case format, including the
changes made by previous hooks.
* `entity` is the original `SEOEntity`.
* `config` is the `SEOConfig` that carried the hook.
## Hook points { #points }
| Name | When it runs |
|---|---|
| `post_process` | At the end of the build, before returning |
`post_process` is the only built-in hook point.
## Order and scoping { #order }
Hooks run in registration order; the last writer of a field wins. Because the
registry is part of the config, hooks are scoped: a config without hooks is
unaffected.
=== "Python"
```python
hooks_a = HookRegistry()
hooks_a.register("post_process", lambda p, e, c: {**p, "site": "A"})
hooks_b = HookRegistry()
hooks_b.register("post_process", lambda p, e, c: {**p, "site": "B"})
a = build_seo_payload(entity, "/x", config_a) # config_a has hooks_a
b = build_seo_payload(entity, "/x", config_b) # config_b has hooks_b
assert a["site"] == "A"
assert b["site"] == "B"
```
## Managing hooks { #manage }
| Python | JavaScript | Purpose |
|---|---|---|
| `register` | `register` | Add a hook |
| `hook` | `hook` | Decorator form |
| `unregister` | `unregister` | Remove a hook |
| `run` | `run` | Run all hooks for a name |
| `clear` | `clear` | Remove all hooks, or those under a name |
| `get_registered` | `size` | Inspect the registry |
## Determinism and purity { #purity }
Keep hooks pure: no clock reads, no random values, no network calls. A hook
that reads the environment breaks the determinism guarantee for the config
that carries it.
If a hook raises, the exception propagates and the remaining hooks are
skipped. Errors should be loud; a failing hook is a bug.
## Recap { #recap }
* Hooks post-process the payload and return it.
* They are config-scoped, ordered, and deterministic when kept pure.
* Use `SEOOverrides` for per-page changes and hooks for site-wide ones.
========================================================================
PAGE: https://easeo.emiliano-go.com/guides/
========================================================================
# Guides { #guides }
Task-oriented walkthroughs for the things people do most with easeo.
## Pages { #pages }
* [Custom JSON-LD](custom-schemas.md#custom-jsonld): per-page overrides and
registered generators.
* [Hooks](hooks.md#hooks): config-scoped post-processing.
* [Contracts in CI](contracts-in-ci.md#contracts-in-ci): enforce SEO intent in
your pipeline.
* [Framework Recipes](framework-recipes.md#framework-recipes): end-to-end
patterns per framework.
* [Migration from seoslug](migration-from-seoslug.md#migration-from-seoslug):
the API mapping and what changed.
## When to use which mechanism { #which }
| Need | Use |
|---|---|
| Change one field on one page | `SEOOverrides` |
| Same schema shape for a whole type | `SchemaRegistry` |
| Add a field to every payload | `HookRegistry` |
| Assert SEO in CI | SEO contract |
| Fix a URL shape globally | `URLPolicy` |
========================================================================
PAGE: https://easeo.emiliano-go.com/guides/migration-from-seoslug/
========================================================================
# Migration from seoslug { #migration-from-seoslug }
## Overview
easeo is the Rust rewrite of seoslug. The core API is similar but the implementation is now Rust with Python and JavaScript bindings.
## API Mapping
| seoslug | easeo |
|---------|-------|
| `seoslug.SEOConfig` | `easeo.SEOConfig` |
| `seoslug.SEOEntity` | `easeo.SEOEntity` |
| `seoslug.build_seo_payload(entity, route, config, overrides=None)` | same signature |
| `seoslug.build_seo_payload_dict()` | `easeo.build_seo_payload_dict()` |
| `seoslug.build_seo_payload_async()` | `easeo.build_seo_payload_async()` |
| `seoslug.URLPolicy` | `easeo.URLPolicy` |
| `seoslug.SchemaRegistry` | `easeo.SchemaRegistry` (callables supported) |
| `seoslug.hook` / `register_hook` | `easeo.HookRegistry` (config-scoped) |
| `seoslug.factories` | `from_blog_post`, `from_product`, `from_faq` |
| `seoslug.SEOError` | `easeo.EaseoError` |
| `payload["title"]`, `payload == dict` | supported |
## What Changed
- Rust core instead of pure Python
- Contract system added
- detrack absorbed into core
- **Hooks are config-scoped only.** seoslug had a global registry; easeo attaches
hooks to `SEOConfig` so the builder stays pure and deterministic.
- **`SchemaRegistry.register()` accepts callables.** It is no longer Rust-only.
- Framework adapters are separate packages
## What Stays the Same
- `build_seo_payload(entity, route, config, overrides=None)` signature
- Deterministic output
- Framework-agnostic core
- `render_html()`, `to_dict()`, `hash()`, `etag()`
- Schema registry
- Dict-compatible, comparable payloads
- `except ValueError` still catches easeo errors
## Installation
```bash
# Old
pip install seoslug
# New
pip install easeo
```
## Code Changes
Minimal changes required:
```python
# Old
from seoslug import SEOConfig, SEOEntity, build_seo_payload
# New
from easeo import SEOConfig, SEOEntity, build_seo_payload
```
The API is intentionally compatible.
========================================================================
PAGE: https://easeo.emiliano-go.com/
========================================================================
easeo documentation
`easeo` is a deterministic SEO metadata generator. It turns a content entity,
a route path, and a site configuration into one structured payload: canonical
URL, title, description, robots directives, Open Graph, Twitter Cards, and
JSON-LD. The core is written in Rust and shipped to Python and
JavaScript/TypeScript, so the same inputs produce byte-for-byte identical
output in every language.
```text
content entity + route + config
|
v
easeo (library)
|
v
SEOPayload
|
+--> framework adapter -->
|
+--> SEO contract --> validation
```
## What easeo is { #what-easeo-is }
- A Rust workspace with three crates:
- [`easeo-core`](reference/rust-api.md): all the logic, with no I/O.
- `easeo-python`: PyO3 bindings.
- `easeo-node`: napi-rs bindings.
- One primary function: `build_seo_payload(entity, route, config)`.
- Pure and deterministic: no timestamps, no randomness, no environment reads.
- Framework-agnostic: adapters for Next.js, Astro, Vite, Nuxt, SvelteKit,
React, FastAPI, Django, Flask, and Zensical.
## Why easeo { #why-easeo }
- **Deterministic output.** Same inputs, same bytes. Snapshot test it, hash
it, cache it forever, diff it across deployments.
- **One implementation, three languages.** The Rust core guarantees that
Python and JavaScript agree, verified by cross-language conformance tests.
- **Contract-first.** Encode your SEO intent as a machine-readable contract
and fail the build when it drifts.
- **Zero ceremony.** One function call returns a payload that renders itself
to safe, ready-to-inject head HTML.
## What easeo is not { #what-easeo-is-not }
- **Not an SEO crawler.** It does not fetch your site.
- **Not a score generator.** It does not grade content.
- **Not a keyword tool.** It formats the data you give it.
- **Not a browser automation framework.** It performs no I/O.
- **Not an analytics platform.** It stores no state.
## Quick start { #quick-start }
=== "Python"
```bash
pip install easeo
```
```python
from easeo import SEOConfig, SEOEntity, build_seo_payload
config = SEOConfig(
canonical_host="example.com",
public_base_url="https://example.com",
site_name="Example",
)
entity = SEOEntity(
entity_type="post",
title="Hello World",
excerpt="An example post.",
)
payload = build_seo_payload(entity, "/blog/hello", config)
print(payload.render_html())
```
=== "JavaScript"
```bash
npm install @easeo/core
```
```js
const { buildSeoPayload } = require("@easeo/core");
const payload = buildSeoPayload(
{ entityType: "post", title: "Hello World", description: "An example post." },
"/blog/hello",
{ canonicalHost: "example.com", publicBaseUrl: "https://example.com" }
);
console.log(payload.renderHtml());
```
=== "Rust"
```rust
use easeo_core::{SEOConfig, SEOEntity, EntityType, build_seo_payload};
let config = SEOConfig {
canonical_host: "example.com".into(),
public_base_url: "https://example.com".into(),
..Default::default()
};
let entity = SEOEntity {
entity_type: EntityType::Post,
title: Some("Hello World".into()),
excerpt: Some("An example post.".into()),
..Default::default()
};
let payload = build_seo_payload(&entity, "/blog/hello", &config)?;
assert_eq!(payload.canonical, "https://example.com/blog/hello");
```
## Guides { #guides }
- [Tutorial](tutorial/index.md): install, first payload, fallbacks, rendering,
contracts, configuration.
- [Concepts](concepts/index.md): determinism, the entity and payload models,
fallback chains, URL normalization, schemas, validation.
- [Guides](guides/index.md): custom JSON-LD, hooks, contracts in CI, framework
recipes, migration.
- [Reference](reference/python-api.md): Python, JavaScript, and Rust APIs.
- [Integrations](integrations/index.md): per-framework setup.
- [Recipes](recipes/index.md): real-world patterns.
## Repository layout { #repository-layout }
```text
easeo/
├── Cargo.toml # Rust workspace
├── pyproject.toml # maturin / Python package
├── README.md
├── LICENSE
├── crates/
│ ├── easeo-core/ # all logic
│ ├── easeo-python/ # PyO3 bindings
│ └── easeo-node/ # napi-rs bindings
├── packages/core/ # @easeo/core wrapper
├── integrations/ # JS framework integrations
├── python/easeo/ # Python package and adapters
├── tests/ # Python, JavaScript, conformance
└── docs/
└── (this directory)
```
## License { #license }
MIT.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/astro/
========================================================================
# Astro { #astro }
`@easeo/astro` is a build-time integration. It injects the site config into the
Vite define map and, optionally, emits an SEO contract after the build.
## Install { #install }
```bash
npm install @easeo/astro
```
## Usage { #usage }
```js
// astro.config.mjs
import { defineConfig } from "astro/config";
import easeo from "@easeo/astro";
export default defineConfig({
integrations: [
easeo({
config: {
canonicalHost: "example.com",
publicBaseUrl: "https://example.com",
},
// Optional: write /.easeo/contract.json after the build.
contract: { canonicalHost: "example.com", scheme: "https" },
}),
],
});
```
## Hooks it registers { #hooks }
| Hook | Effect |
|---|---|
| `astro:config:setup` | Defines `__EASEO_CONFIG__` for use in components |
| `astro:build:done` | Writes `.easeo/contract.json` when `contract` is set |
## Emitting the contract { #contract }
The contract is written to the build output directory:
```text
dist/
└── .easeo/
└── contract.json
```
Commit it or upload it as a build artifact, then gate deployments on a diff.
See [Contracts in CI](../guides/contracts-in-ci.md).
## Notes { #notes }
* The output directory is resolved with `fileURLToPath`, so paths with spaces
work correctly.
* The contract file uses the canonical snake_case format that matches the
published JSON schema.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/django/
========================================================================
# Django { #django }
The Django adapter provides template tags and a function API that read their
config from Django settings.
## Install { #install }
```bash
pip install "easeo[django]"
```
## Configure { #configure }
```python
# settings.py
EASEO = {
"canonical_host": "example.com",
"public_base_url": "https://example.com",
"site_name": "Example",
"title_template": "{title} - Example",
}
```
If `EASEO` is missing, the adapter warns and falls back to `localhost`.
## Template tags { #tags }
Register the library and call the tags:
```django
{% load easeo_tags %}
{% easeo_head entity request.path %}
```
| Tag | Output |
|---|---|
| `{% easeo_head entity route %}` | Full `` block |
| `{% easeo_title entity %}` | Just the `` tag |
| `{% easeo_meta entity %}` | Just the meta description |
`easeo_title` and `easeo_meta` read the route from the request in the template
context.
## Function API { #function }
```python
from easeo.adapters.django import seo_head, easeo_title, easeo_meta
html = seo_head(entity, "/blog/post")
```
Pass an explicit config as the third argument to bypass settings:
```python
from easeo import SEOConfig
html = seo_head(entity, "/blog/post", SEOConfig(...))
```
## Notes { #notes }
* Output is marked safe because the payload escapes its values.
* Entities need `entity_type`, `title`, and `description`; missing
`entity_type` defaults to `page`.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/fastapi/
========================================================================
# FastAPI { #fastapi }
The FastAPI adapter wraps an `SEOConfig` and builds payloads per route.
## Install { #install }
```bash
pip install "easeo[fastapi]"
```
## Usage { #usage }
```python
from fastapi import FastAPI
from easeo import SEOConfig
from easeo.adapters.fastapi import EaseoSEO
app = FastAPI()
seo = EaseoSEO(
SEOConfig(
canonical_host="example.com",
public_base_url="https://example.com",
)
)
@app.get("/products/{slug}")
def product(slug: str):
return seo.for_entity(product, f"/products/{slug}")
```
`for_entity(entity, route)` returns a plain dict, ready for a JSON response or
a template.
## Async endpoints { #async }
The payload build is fast and releases the GIL. If you build many payloads in a
request, or want to keep the event loop free, use the async builder:
```python
from easeo import build_seo_payload_async
@app.get("/products/{slug}")
async def product(slug: str):
return (await build_seo_payload_async(product, f"/products/{slug}", config)).to_dict()
```
## Notes { #notes }
* `EaseoSEO(None)` raises `ValueError`; a config is required.
* `for_entity` accepts any object with `entity_type`, `title`, and
`description` or `excerpt` attributes. Missing `entity_type` defaults to
`page`.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/flask/
========================================================================
# Flask { #flask }
The Flask adapter registers a context processor and exposes a `for_entity`
helper.
## Install { #install }
```bash
pip install "easeo[flask]"
```
## Usage { #usage }
```python
from flask import Flask
from easeo import SEOConfig
from easeo.adapters.flask import Easeo
app = Flask(__name__)
easeo = Easeo(
app,
SEOConfig(
canonical_host="example.com",
public_base_url="https://example.com",
)
)
```
## Templates { #templates }
The adapter registers a `seo_head(entity, route)` helper:
```jinja
{{ easeo_head(entity, request.path) | safe }}
```
The return value is `Markup`, so the `| safe` filter is optional.
## Direct use { #direct }
```python
payload = easeo.for_entity(entity, "/blog/post")
# returns a plain dict
```
## Deferred init { #deferred }
For application factories, construct without an app and initialize later:
```python
easeo = Easeo(config=config)
easeo.init_app(app)
```
`init_app` without a config raises `ValueError`.
## Notes { #notes }
* `for_entity` returns a dict; the template helper returns HTML.
* Entities need `entity_type`, `title`, and `description`; missing
`entity_type` defaults to `page`.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/
========================================================================
# Integrations { #integrations }
easeo ships adapters for the most common frameworks. They are thin: each one
calls `build_seo_payload` and hands the result to the framework's native
metadata mechanism.
## JavaScript / TypeScript { #javascript }
| Framework | Package | Entry point |
|---|---|---|
| [Next.js](next.md#nextjs) | `@easeo/next` | `easeoMetadata()` |
| [Astro](astro.md#astro) | `@easeo/astro` | build integration plus contract emission |
| [Vite](vite.md#vite) | `@easeo/vite` | `transformIndexHtml` plugin |
| [Nuxt](nuxt.md#nuxt) | `@easeo/nuxt` | `useEaseoSeo()` composable |
| [SvelteKit](sveltekit.md#sveltekit) | `@easeo/sveltekit` | `buildEaseoPayload()` plus `` |
| [React](react.md#react) | `@easeo/react` | `` component |
All JavaScript integrations support both default and named imports:
```ts
import easeoMetadata from "@easeo/next"; // default
import { easeoMetadata } from "@easeo/next"; // named
```
## Python { #python }
| Framework | Import |
|---|---|
| [FastAPI](fastapi.md#fastapi) | `from easeo.adapters.fastapi import EaseoSEO` |
| [Django](django.md#django) | `from easeo.adapters.django import seo_head` |
| [Flask](flask.md#flask) | `from easeo.adapters.flask import Easeo` |
| [Zensical](zensical.md#zensical) | `easeo.contrib.zensical` markdown extension |
Python adapters are lazy and installed as extras: `pip install easeo[fastapi]`,
`[django]`, `[flask]`, `[zensical]`, or `[all]`.
## Choosing an approach { #choosing }
* If your framework has a native metadata API, use the adapter that targets it.
Next.js is the clearest example.
* If it does not, use the render helpers and inject `payload.render_html()`
into your template.
* For static sites, generate payloads at build time and commit the contract.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/next/
========================================================================
# Next.js { #nextjs }
`@easeo/next` converts an easeo payload into a native Next.js `Metadata`
object. Use it inside `generateMetadata`; there is no HTML manipulation.
## Install { #install }
```bash
npm install @easeo/next
```
## Usage { #usage }
```tsx
// app/products/[slug]/page.tsx
import { easeoMetadata } from "@easeo/next";
export async function generateMetadata({ params }) {
const product = await getProduct(params.slug);
return easeoMetadata({
entity: {
entityType: "product",
title: product.name,
description: product.description,
},
route: `/products/${product.slug}`,
config: {
canonicalHost: "example.com",
publicBaseUrl: "https://example.com",
},
});
}
```
## What it returns { #returns }
| Next.js key | Source |
|---|---|
| `title` | `payload.title` |
| `description` | `payload.description` |
| `alternates.canonical` | `payload.canonical` |
| `robots` | `payload.robots` |
| `openGraph` | title, description, url, siteName, images, locale, type |
| `twitter` | card, title, description, images, site, creator |
`config` is required: the core rejects an empty `canonicalHost`.
## Notes { #notes }
* Images are only set when the payload has one; otherwise the `images` key is
`undefined` and Next.js omits it.
* Both `import easeoMetadata from ...` and
`import { easeoMetadata } from ...` work.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/nuxt/
========================================================================
# Nuxt { #nuxt }
`@easeo/nuxt` provides a lightweight module that stores the site config and a
`useEaseoSeo()` composable for pages.
## Install { #install }
```bash
npm install @easeo/nuxt
```
## Usage { #usage }
```ts
import { useEaseoSeo } from "@easeo/nuxt";
useEaseoSeo({
entity: {
entityType: "post",
title: article.title,
description: article.description,
},
route: `/blog/${article.slug}`,
config: {
canonicalHost: "example.com",
publicBaseUrl: "https://example.com",
},
});
```
`useEaseoSeo()` always returns the built payload. When Nuxt's `useHead()`
auto-import is available in a page or component setup context, the composable
also pushes the tags into the page head.
## Module config { #module }
You can register the module and store the config once:
```ts
// nuxt.config.ts
import easeoModule from "@easeo/nuxt";
export default defineNuxtConfig({
modules: [easeoModule({ config: { canonicalHost: "example.com", publicBaseUrl: "https://example.com" } })],
});
```
After that, `useEaseoSeo()` calls do not need to pass `config`.
## Notes { #notes }
* Passing `config` per call always wins over the module-level config.
* If neither is present, `useEaseoSeo()` throws with a clear message.
* Both default and named imports are supported.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/react/
========================================================================
# React { #react }
`@easeo/react` provides ``, a component that keeps
`document.head` in sync with an easeo payload on the client.
## Install { #install }
```bash
npm install @easeo/react
```
## Usage { #usage }
```tsx
import { EaseoHead } from "@easeo/react";
```
The component renders nothing. It builds the payload on render and updates the
document head.
## Server rendering { #ssr }
`` is SSR-safe: when `document` is unavailable, it returns `null`
and does nothing. For SSR or SSG, put the rendered head into your HTML
template:
```tsx
const payload = buildSeoPayload(entity, route, config);
return (
{children}
);
```
## Notes { #notes }
* The component returns `null`; it is not a visual element.
* `renderHtml()` output is already escaped, so it is safe to inject.
* Both default and named imports are supported.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/sveltekit/
========================================================================
# SvelteKit { #sveltekit }
`@easeo/sveltekit` gives you `buildEaseoPayload()` and a ``
component that renders into ``.
## Install { #install }
```bash
npm install @easeo/sveltekit
```
## Usage { #usage }
```svelte
```
`buildEaseoPayload(entity, route, config)` is a straight pass-through to
`buildSeoPayload`; the component then renders the payload's fields.
## What the component renders { #component }
`` emits the title, description, canonical link, robots, Open
Graph, Twitter, and JSON-LD tags.
The JSON-LD payload is serialized with `<` escaped before it is injected, so a
value containing a closing script tag cannot break out of the block.
## One page, one head { #one-head }
Use `` once per page. It writes into SvelteKit's ``,
which merges cleanly with other head content.
## Notes { #notes }
* The package is ESM only and exports `EaseoHead.svelte` explicitly.
* `buildEaseoPayload` and `EaseoHead` can also be imported from the package
root.
========================================================================
PAGE: https://easeo.emiliano-go.com/integrations/vite/
========================================================================
# Vite { #vite }
`@easeo/vite` injects SEO tags into the built `index.html` through Vite's
`transformIndexHtml` hook.
## Install { #install }
```bash
npm install @easeo/vite
```
## Usage { #usage }
```js
// vite.config.mjs
import { defineConfig } from "vite";
import easeo from "@easeo/vite";
export default defineConfig({
plugins: [
easeo({
config: {
canonicalHost: "example.com",
publicBaseUrl: "https://example.com",
siteName: "Example",
},
}),
],
});
```
## What it injects { #injects }
For each built HTML page, the plugin adds:
* ``
* ``
* ``
* ``
* Open Graph tags
* Twitter Card tags
* a JSON-LD `
```
!!! warning "Escape before injecting"
`render_html()` returns an HTML string. In a template engine, mark it as
safe (Jinja2 `|safe`, Django `mark_safe`, Svelte `{@html}`) only because
the payload has already escaped the values it renders. Do not
hand-interpolate raw entity fields into your own ``.
## Granular rendering { #granular }
When you need to place sections separately, render them individually.
| Python | JavaScript | Output |
|---|---|---|
| `render_html()` | `renderHtml()` | full `` block |
| `render_opengraph()` | `renderOpengraph()` | `og:*` tags only |
| `render_twitter()` | `renderTwitter()` | `twitter:*` tags only |
| `render_jsonld()` | `renderJsonld()` | JSON-LD `
```
## Why rendering lives in the core { #why-core }
The HTML is generated by the Rust core, not by a template. That means the same
payload produces the same markup in Python and JavaScript, and the escaping
rules are identical everywhere. See [Payload Model](../concepts/payload-model.md)
for the exact tag order.
## Recap { #recap }
* `render_html()` produces a complete, escaped `` block.
* Granular renderers are available for custom layouts.
* Framework adapters call these for you.
**Next:** [Contracts](contracts.md#contracts).