SEO — cascading metadata, sitemaps, and AI suggestions
Granit.Cms.Seo owns everything a search engine and a social card need: title and meta
description, canonical URL, robots directives, Open Graph and Twitter cards, hreflang
alternates, JSON-LD, and the generated sitemap.xml / robots.txt / manifest.webmanifest.
Granit.Cms.Seo.AI adds a reviewable layer that proposes those fields with an LLM.
The cascade
Section titled “The cascade”Effective SEO is resolved through three tiers — framework defaults, then per-site defaults,
then per-content overrides. ISeoResolver flattens them into a concrete EffectiveSeo with
no inheritance left to compute.
flowchart LR
F["Framework defaults"] --> S["SiteSeoDefaults<br/>(per site)"]
S --> M["SeoMetadata<br/>(per page + culture)"]
M --> E["EffectiveSeo<br/>(fully resolved)"]
SiteSeoDefaults(one per site): title template, site name, default description/robots, default OG image, canonical host,robots.txtrules, PWA manifest, sitemap sizing, and theEnableAutomaticSeoGenerationopt-in.SeoMetadata(sparse, keyed(SiteId, ContentType, ContentId, Culture)): per-page overrides — title, description, keywords, canonical, robots, OG/Twitter, JSON-LD extras, hreflang. Only the fields an editor set are stored.
EffectiveSeo seo = await resolver.ResolveEffectiveAsync( new SeoResolutionRequest(contentKey, siteId, contentTitle, contentDescription), ct);SeoMetadata carries a ConcurrencyStamp (a stale save returns 409) and a review status
so an editorial workflow can track what’s been checked.
Generated files
Section titled “Generated files”Three builders turn site defaults + published pages into the files crawlers expect — served anonymously, ETag-aware, and cached:
| Route | Content type | Notes |
|---|---|---|
GET /api/cms/seo/sites/{siteId}/sitemap.xml | application/xml | Root or index; 304 on If-None-Match |
GET …/sitemap/{file} | application/xml | Child files (sharded at SitemapMaxUrlsPerFile, max 50 000) |
GET …/robots.txt | text/plain | Always 200, even with no defaults |
GET …/manifest.webmanifest | application/manifest+json | 404 when no manifest configured |
A recurring SitemapRebuildJob (every 2 min) warms the sitemap cache for sites marked dirty
by publish events, so the first crawler hit is never a cold rebuild. JSON-LD is assembled by a
registry of builders (WebPage, BreadcrumbList, Organization, WebSite, Article) and
previewable per page.
Admin & preview
Section titled “Admin & preview”| Route | Permission | Purpose |
|---|---|---|
GET …/metadata/{contentType}/{contentId}/{culture} | Cms.Seo.Read | Raw saved overrides |
GET …/{culture}/effective | Cms.Seo.Read | Cascade-resolved result |
PUT …/{culture} · DELETE …/{culture} | Cms.Seo.Manage | Upsert / delete overrides (409 on stale stamp) |
GET …/defaults · PUT …/defaults | Cms.Seo.Read / Manage | Site defaults |
GET …/preview/serp · …/preview/og · …/preview/jsonld | Cms.Seo.Read | Render the SERP snippet, OG card, JSON-LD |
GET …/metadata (grid) | Cms.Seo.Read | Audit grid — filters: missing description, no canonical, title too long, missing OG image |
A nightly SeoAuditJob (opt-in) sweeps for the same drift the grid surfaces.
AI suggestions
Section titled “AI suggestions”Granit.Cms.Seo.AI generates titles, descriptions, keywords, OG alt text, and JSON-LD — but
never writes them straight to live metadata. It produces a reviewable suggestion an editor
applies field-by-field.
It runs on the framework’s typed-output primitive, IStructuredCompletion
(ADR-064) — so provider choice, workspace routing, quota, usage tracking, and prompt-injection
isolation are all inherited; the module never touches a vendor SDK.
public interface ISeoMetadataGenerator{ Task<SeoGenerationResult> GenerateAsync( SeoGenerationRequest request, CancellationToken cancellationToken = default);}The content is sanitized and wrapped before it reaches the model; the page title and culture ride as labeled context (not in the instruction), keeping untrusted page content off the prompt-injection surface. A 7-day fingerprint (model + template version + content hash + culture) makes re-generation idempotent — unchanged content reuses the pending suggestion.
| Route | Permission | Purpose |
|---|---|---|
POST /api/cms/seo/ai/suggest | Cms.Seo.AI.Generate | Generate a suggestion (idempotent within 7 days) |
GET …/suggestions | Cms.Seo.AI.Read | The review inbox (filter by site/type/status) |
GET …/suggestions/{id}/diff | Cms.Seo.AI.Read | Current vs proposed, per field |
POST …/suggestions/{id}/apply | Cms.Seo.AI.Apply | Write selected fields to live metadata |
POST …/suggestions/{id}/reject | Cms.Seo.AI.Apply | Dismiss (with optional reason) |
POST …/sites/{siteId}/audit | Cms.Seo.AI.Generate | Enqueue a bulk generation sweep (202) |
Sites that opt in via EnableAutomaticSeoGeneration are swept nightly
(cms-seo-ai-nightly-sweep), enqueuing one bulk run each — suggestions land in the inbox for
review, never auto-applied.
OG images from the media library
Section titled “OG images from the media library”Granit.Cms.Seo.Documents is an optional glue package that lets an OG image live in the
Documents media library instead of a hand-typed URL. Add it when
editors pick social cards from uploaded assets and you want stable, CDN-frontable links plus
correct dimensions and MIME type on every crawl — without denormalising URLs by hand.
The SEO base module ships an IOgImageProvider seam with a no-op default: an author-supplied
OgImage.Url passes through, and a media-library reference (OgImage.DocumentId) stays inert.
This package replaces that default with DocumentRenditionsOgImageProvider, which resolves the
reference through the Documents library’s IDocumentRenderResolver:
- When
OgImage.DocumentIdis set, it requests aRenditionType.Webrendition and fills the denormalisedUrl,Dimensions, andMimeTypethat social platforms cache, pinning theVersionIdthe resolver actually served. - An author-supplied URL — or a reference whose document is missing, deleted, or not yet rendered — is preserved untouched, so a previously denormalised URL survives.
flowchart LR
O["OgImage<br/>(DocumentId set)"] --> P["DocumentRenditionsOgImageProvider"]
P --> R["IDocumentRenderResolver<br/>(RenditionType.Web)"]
R --> E["OgImage<br/>Url · Dimensions · MimeType · VersionId"]
Register the module — it declares [DependsOn] on the SEO base module (the seam) and the
Documents resolution module (the resolver), and Replaces the no-op provider:
[DependsOn(typeof(GranitCmsSeoDocumentsModule))]public sealed class WebsiteModule : GranitModule;See also
Section titled “See also”- Structured Completion — the typed-output primitive Seo.AI builds on
- Sites & Pages — the content SEO describes
- Documents — the media library that backs
DocumentId-based OG images