Skip to content

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.

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.txt rules, PWA manifest, sitemap sizing, and the EnableAutomaticSeoGeneration opt-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.

Three builders turn site defaults + published pages into the files crawlers expect — served anonymously, ETag-aware, and cached:

RouteContent typeNotes
GET /api/cms/seo/sites/{siteId}/sitemap.xmlapplication/xmlRoot or index; 304 on If-None-Match
GET …/sitemap/{file}application/xmlChild files (sharded at SitemapMaxUrlsPerFile, max 50 000)
GET …/robots.txttext/plainAlways 200, even with no defaults
GET …/manifest.webmanifestapplication/manifest+json404 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.

RoutePermissionPurpose
GET …/metadata/{contentType}/{contentId}/{culture}Cms.Seo.ReadRaw saved overrides
GET …/{culture}/effectiveCms.Seo.ReadCascade-resolved result
PUT …/{culture} · DELETE …/{culture}Cms.Seo.ManageUpsert / delete overrides (409 on stale stamp)
GET …/defaults · PUT …/defaultsCms.Seo.Read / ManageSite defaults
GET …/preview/serp · …/preview/og · …/preview/jsonldCms.Seo.ReadRender the SERP snippet, OG card, JSON-LD
GET …/metadata (grid)Cms.Seo.ReadAudit 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.

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.

RoutePermissionPurpose
POST /api/cms/seo/ai/suggestCms.Seo.AI.GenerateGenerate a suggestion (idempotent within 7 days)
GET …/suggestionsCms.Seo.AI.ReadThe review inbox (filter by site/type/status)
GET …/suggestions/{id}/diffCms.Seo.AI.ReadCurrent vs proposed, per field
POST …/suggestions/{id}/applyCms.Seo.AI.ApplyWrite selected fields to live metadata
POST …/suggestions/{id}/rejectCms.Seo.AI.ApplyDismiss (with optional reason)
POST …/sites/{siteId}/auditCms.Seo.AI.GenerateEnqueue 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.

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.DocumentId is set, it requests a RenditionType.Web rendition and fills the denormalised Url, Dimensions, and MimeType that social platforms cache, pinning the VersionId the 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;