easeo
Deterministic SEO metadata generation for content platforms.
Install
# Python
pip install easeo
# Node.js
npm install @easeo/core
Quick start
Python
from easeo import SEOConfig, SEOEntity, build_seo_payload
config = SEOConfig(
canonical_host="example.com",
public_base_url="https://example.com",
)
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 / TypeScript
import { buildSeoPayload } from "@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
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).unwrap();
assert_eq!(payload.canonical, "https://example.com/blog/hello");
Why easeo
Other SEO libraries do too much (scoring, keyword analysis, content rewriting),
while easeo does one thing and does it well: generate deterministic SEO
metadata from content entities.
Given the same inputs, easeo always produces the same output. No timestamps,
no randomness, no environment reads, no hidden I/O. This makes SEO metadata
something you can:
- snapshot test: assert against expected payloads
- hash: generate stable ETags
- cache: safely memoize results
- diff: detect changes across builds
- validate in CI: commit your SEO intent as code
Content Entity
|
v
easeo
|
+-- canonical URL
+-- title & description
+-- robots directives
+-- Open Graph
+-- Twitter Cards
+-- JSON-LD
|
v
Deterministic Payload
Architecture
- Rust core (
easeo-core): all logic, no I/O dependencies - Python bindings (
easeo-python): via PyO3 - Node bindings (
easeo-node): via napi-rs - Framework integrations: Next.js, Astro, Vite, Nuxt, SvelteKit, React, FastAPI, Django, Flask, Zensical
The Rust core guarantees identical behavior across languages. Same entity, same route, same config, same payload everywhere.
Features
- 11 entity types: home, post, page, video, taxonomy, search, product, organization, local_business, faq, other
- URL normalization: HTTPS enforcement, lowercase paths, trailing slash control, duplicate slash collapse, tracking parameter removal
- JSON-LD schemas: WebSite, WebPage, Article, Product, Organization, LocalBusiness, FAQPage, BreadcrumbList, plus custom schemas via registry
- Open Graph: full og:type, title, description, image, locale, audio, video
- Twitter Cards: summary_large_image by default, with image alt, site, creator
- Robots directives: index/noindex, follow/nofollow, max-snippet, max-image-preview, max-video-preview
- SEO contracts: machine-readable SEO intent for CI validation
- Validation: built-in checks for title length, description, canonical format, OG image, robots directives
- Hashing: SHA-256 payload hashing and ETag generation
- HTML rendering: complete
<head>snippet with meta tags, OG, Twitter, JSON-LD - Extension points: config-scoped hooks and schema registries
Framework integrations
| Framework | Package | API |
|---|---|---|
| Next.js | @easeo/next |
easeoMetadata() returns a Next.js Metadata object |
| Astro | @easeo/astro |
Build-time integration with contract emission |
| Vite | @easeo/vite |
transformIndexHtml hook |
| Nuxt | @easeo/nuxt |
useEaseoSeo() composable |
| SvelteKit | @easeo/sveltekit |
buildEaseoPayload() and <EaseoHead /> |
| React | @easeo/react |
<EaseoHead /> component |
Python framework adapters
| Framework | Import |
|---|---|
| FastAPI | from easeo.adapters.fastapi import EaseoSEO |
| Django | from easeo.adapters.django import seo_head |
| Flask | from easeo.adapters.flask import Easeo |
| Zensical | from easeo.contrib.zensical import EaseoExtension |
API
build_seo_payload(entity, route_path, config, overrides=None)
The core function. Takes an entity, route, and config. Returns a deterministic
SEOPayload.
| Parameter | Type | Description |
|---|---|---|
entity |
SEOEntity |
Content entity with type, title, excerpt, images, etc. |
route_path |
str |
Route path, e.g. "/blog/hello" |
config |
SEOConfig |
Site-wide configuration (host, base URL, defaults) |
overrides |
SEOOverrides | None |
Per-call overrides (highest precedence) |
Returns: SEOPayload, a structured payload with all SEO fields.
SEOConfig
Site-wide configuration.
| Field | Type | Description |
|---|---|---|
canonical_host |
str |
Canonical hostname, e.g. "example.com" (host only, no scheme) |
public_base_url |
str |
Full base URL for path resolution, e.g. "https://example.com" |
url_policy |
URLPolicy | None |
URL normalization policy (HTTPS, lowercase, trailing slash, tracking params) |
default_robots |
Robots | None |
Default robots directives for regular pages |
search_robots |
Robots | None |
Robots directives for search-type pages (default: noindex,follow) |
default_og_image |
SEOImage | None |
Fallback OG image |
site_name |
str | None |
Site name (og:site_name, title template) |
title_template |
str | None |
Template for titles, e.g. "{title} - My Site" |
publisher_name |
str | None |
Organization/publisher name for schemas |
publisher_logo |
str | None |
Publisher logo URL for schemas |
locale |
str | None |
og:locale, e.g. "en_US" |
locale_alternate |
list[str] | None |
Alternate locales |
twitter_site |
str | None |
Twitter @handle for twitter:site |
auto_generate_schema |
bool |
Auto-generate JSON-LD from entity type (default: True) |
emit_warnings |
bool |
Collect validation warnings (default: False) |
schema_type_map |
dict | None |
Override entity-type to schema.org type mapping |
search_url_template |
str | None |
Search URL for the homepage WebSite SearchAction |
hooks |
HookRegistry | None |
Config-scoped post-processing |
schema_registry |
SchemaRegistry | None |
Config-scoped JSON-LD generators |
SEOEntity
Content entity representation.
| Field | Type | Description |
|---|---|---|
entity_type |
str |
One of: home, post, page, video, taxonomy, search, product, organization, local_business, faq, other |
title |
str | None |
Page title |
excerpt |
str | None |
Short description |
slug |
str | None |
Entity slug |
body_html |
str | None |
Full content (used to derive a description snippet when no excerpt) |
status |
str | None |
Publication status (non-published becomes noindex) |
featured_image |
SEOImage | None |
Primary image |
published_at |
str | None |
ISO date/datetime |
updated_at |
str | None |
ISO date/datetime |
author_name |
str | None |
Author display name |
breadcrumbs |
list[Breadcrumb] | None |
Breadcrumb trail (appended as BreadcrumbList JSON-LD) |
faq_items |
list[FAQItem] | None |
FAQ entries (FAQPage schema) |
sku |
str | None |
Product SKU |
price |
str | None |
Product price |
price_currency |
str | None |
ISO currency code, e.g. "USD" |
availability |
str | None |
Product availability |
same_as |
list[str] | None |
sameAs URLs (Organization schema) |
address |
str | None |
Address (LocalBusiness schema) |
SEOPayload
The output. Structured, hashable, renderable.
| Field | Type | Description |
|---|---|---|
title |
str |
Resolved title |
description |
str |
Resolved description |
canonical |
str |
Normalized canonical URL |
robots |
str |
Resolved robots directives, e.g. "index,follow" |
og |
OGPayload |
Open Graph metadata |
twitter |
TwitterPayload |
Twitter Card metadata |
schema_jsonld |
dict | None |
JSON-LD structured data |
Methods:
| Method | Description |
|---|---|
render_html() |
Full <head> HTML snippet |
render_opengraph() |
OG meta tags only |
render_twitter() |
Twitter meta tags only |
render_jsonld() |
JSON-LD <script> tag only (with < escaped to be script-safe) |
to_dict() |
Payload as a canonical snake_case dictionary |
to_json() |
Payload as a canonical JSON string |
hash() |
SHA-256 hash of the payload |
etag() |
HTTP ETag string (quoted) |
Payloads are dict-compatible and comparable:
payload["title"]
payload.get("title")
"title" in payload
payload == other_payload
payload == payload.to_dict()
Custom JSON-LD schemas
Replace the schema for one page with overrides:
from easeo import SEOOverrides, build_seo_payload
payload = build_seo_payload(
entity,
"/podcast/ep-1",
config,
SEOOverrides(schema_jsonld={
"@context": "https://schema.org",
"@type": "Podcast",
"name": "My Podcast",
}),
)
Register a generator for a whole schema type on the config:
from easeo import SchemaRegistry
registry = SchemaRegistry()
@registry.register("Article")
def podcast_episode(entity, config, canonical, title, description, og_image):
return {"@context": "https://schema.org", "@type": "Podcast", "name": title}
config = SEOConfig(..., schema_registry=registry)
Extension points
Hooks post-process the payload. They are config-scoped, so the builder stays a pure function of its inputs:
from easeo import HookRegistry
hooks = HookRegistry()
@hooks.hook("post_process")
def add_generator(payload, entity, config):
payload["generator"] = "easeo"
return payload
config = SEOConfig(..., hooks=hooks)
The JavaScript equivalents are HookRegistry and SchemaRegistry on
@easeo/core, attached through hooks and schemaRegistry on the config.
SEOEntityBuilder
Fluent builder for complex entities.
from easeo import SEOEntityBuilder
entity = (
SEOEntityBuilder("post")
.title("Hello World")
.excerpt("An example post.")
.featured_image("https://example.com/hero.jpg", width=1200, height=630)
.breadcrumb("Home", "/")
.breadcrumb("Blog", "/blog")
.build()
)
Factories
Convenience constructors for common content types.
from easeo import from_blog_post, from_product, from_faq
entity = from_blog_post(title="Hello", body_html="<p>Body</p>", author="Jane")
product = from_product(name="Widget", sku="W-1", price=9.99)
faq = from_faq(questions=[{"question": "Q?", "answer": "A."}])
URLPolicy
Controls canonical URL normalization.
from easeo import URLPolicy
policy = URLPolicy(
enforce_https=True,
lowercase_paths=True,
trailing_slash="never",
collapse_duplicate_slashes=True,
strip_tracking_params=True,
allowed_query_params=["page", "q"],
)
build_seo_payload_dict(entity, route_path, config, overrides=None)
Returns the payload as a plain dictionary. Useful for JSON serialization.
data = build_seo_payload_dict(entity, "/blog/hello", config)
build_seo_payload_async(entity, route_path, config, overrides=None)
Async wrapper that offloads the build to a thread pool. The Rust core releases the GIL, so this genuinely runs off the event loop.
from easeo import build_seo_payload_async
payload = await build_seo_payload_async(entity, "/blog/hello", config)
Fallback resolution
easeo resolves SEO fields through an explicit precedence chain:
SEOOverrides highest priority
|
SEOEntity
|
SEOConfig
|
hardcoded defaults lowest priority
For example:
config.default_og_image
|
entity.featured_image
|
overrides.og_image
This avoids both "everything is global" and "everything is magically inferred".
Errors
All errors inherit from EaseoError, which inherits from ValueError in
Python and Error in JavaScript. Python code that catches ValueError keeps
working.
| Type | Raised when |
|---|---|
InvalidUrlError |
A URL is malformed or a URL policy is invalid |
ConfigurationError |
A config value fails validation |
EntityError |
An entity or overrides value fails validation |
SchemaError |
JSON-LD construction fails |
ContractError |
Contract generation fails |
Documentation
Full documentation lives in docs/ and builds with Zensical. The docs site
uses the easeo Zensical extension, so easeo must be importable by the
interpreter that runs Zensical. uv handles this in one command:
uv run zensical serve # live preview on http://localhost:8000
uv run zensical build # writes site/
Or with a manual environment:
pip install -e ".[zensical]"
zensical serve
Topics include a tutorial, concepts, guides, an API reference, per-framework integrations, and recipes.
To reproduce the exact artifact that CI and Cloudflare Pages deploy (easeo
installed editable, llms-full.txt regenerated, then the build):
bash scripts/build_docs.sh # output in site/
Cloudflare Pages settings: build command bash scripts/build_docs.sh, output
directory site, environment variable PYTHON_VERSION=3.12 (a Rust toolchain
is required). See docs/about/deploying-docs.md for details.
Development
Prerequisites
- Rust (stable)
- Python 3.10+
- Node.js 18+
Build
# Rust core
cargo build --release
# Python bindings
pip install maturin
maturin develop -m crates/easeo-python/Cargo.toml
# Node bindings
cd packages/core
napi build --platform --release --manifest-path ../../crates/easeo-node/Cargo.toml
cp ../../crates/easeo-node/*.node .
Test
# Rust tests
cargo test --workspace
# Python tests
pytest tests/python/
# JavaScript tests
node --test tests/javascript/*.cjs
# Cross-language conformance
python tests/conformance/test_conformance.py
License
MIT
Metadata
Release files for easeo 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| easeo-0.1.0-cp310-abi3-win_amd64.whl | CPython 3.10 | abi3 | Windows x86-64 | Details |
| easeo-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| easeo-0.1.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| easeo-0.1.0-cp310-abi3-macosx_11_0_arm64.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64 | Details |
| easeo-0.1.0-cp310-abi3-macosx_10_12_x86_64.whl | CPython 3.10 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 3.5 MB
Release files / easeo-0.1.0-cp310-abi3-win_amd64.whl
| Download URL | easeo-0.1.0-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 599.1 kB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
496d088de70cce5edd03d95e581506e9663f957ed75ed21382d9549715e45f5a
|
|
BLAKE2b-256 checksum How to use checksums |
bc17e28ae12f1c938be2d969f8a10a5ff83a3874319fc51da67600c078584c62
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / easeo-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | easeo-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 772.4 kB |
| Tags | CPython 3.10 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
c10178a040ef10d9d77977d9de1b87e71684e5ea80c8039ccb25309020487fac
|
|
BLAKE2b-256 checksum How to use checksums |
4dfeceaed7aba8db6c326a00d27670dfac8b52b7163841898acaef0b9554f19b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / easeo-0.1.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | easeo-0.1.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 757.7 kB |
| Tags | CPython 3.10 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
40835cc59452d2cfc0ceab803557ea4cee474b9cca7ff40e9d4bd40a61d86780
|
|
BLAKE2b-256 checksum How to use checksums |
8973e4f643778405d1b5f18fb880dda4fb545c0693dd9c1922f0ad4614821834
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / easeo-0.1.0-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | easeo-0.1.0-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 699.6 kB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
10e1626245c566bb4df0872e29beae3eadba116338128e674755b827e31c5e5a
|
|
BLAKE2b-256 checksum How to use checksums |
154159fef3e0411b4a3c67daf0d6bcb70b11f8cebd7fbf4d58eb65850a73d86a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / easeo-0.1.0-cp310-abi3-macosx_10_12_x86_64.whl
| Download URL | easeo-0.1.0-cp310-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 709.1 kB |
| Tags | CPython 3.10 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
b172e16242a0b5e8a7801fa4e335f01b388e655a983cf93a5baab76d33902169
|
|
BLAKE2b-256 checksum How to use checksums |
f0c89c382a306c2f1aa944265cd0874c262a8da8313e8240a008fb688daf023e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency log