Technical SEO is full of work that sounds small in a planning meeting and turns out to be product infrastructure once you start building it.
“Add a news sitemap” is one of those requests.
If the goal is to be discoverable in Google News, the work is not just about generating XML. You need publication-specific rules. You need clean publisher metadata. You need article schema that matches the content type. You need freshness windows. You need robots coverage. And you need all of it to fit the CMS instead of living as a one-off script on the side.
The changes include publisher support, news sitemap generation, schema mapping, middleware integration, and settings-driven publication names for Articles Today and Magazine. The result is not a checklist item. It is a maintainable publishing capability.
At the center of the implementation is NewsSitemapService, which generates both a sitemap index and publication-specific news sitemaps. It filters down to the right content, respects indexing rules, and only includes Google News metadata for the freshest items.
private const int MaxNewsMetadataUrls = 1000;
private static readonly TimeSpan FreshnessWindow = TimeSpan.FromDays(2);
var items = rootLink.GetAll<TPage>()
.Where(page => page != null)
.Where(includePage)
.Where(page => !page.DisableIndexing && !page.ExcludeFromSiteMap)
.Where(page => page.GetPublishDate() != DateTime.MinValue)
.OrderByDescending(page => page.GetPublishDate())
.ToList();
That two-day freshness window is especially important. It reflects how news content behaves differently from evergreen content. A standard sitemap can list everything. A news sitemap needs to emphasize what is timely while still remaining valid and scalable.
The middleware layer adds another smart touch by extending robots.txt automatically when the news sitemap index exists.
if (!robotsContent.Contains(newsSitemapUrl, StringComparison.InvariantCultureIgnoreCase))
{
var separator = robotsContent.EndsWith(Environment.NewLine, StringComparison.Ordinal)
? string.Empty
: Environment.NewLine;
robotsContent = $"{robotsContent}{separator}Sitemap: {newsSitemapUrl}{Environment.NewLine}";
}
This is the kind of operational polish that prevents good SEO work from becoming brittle. Teams do not have to remember to update a separate file by hand every time infrastructure changes. The platform handles it.
The schema work matters too.
ArticleDetailPageSchemaMapper and its magazine counterpart were updated so publisher information can be derived from settings or landing pages, rather than hard-coded assumptions. That keeps the implementation flexible for multiple publications while still giving Google a clearer representation of authorship, publisher identity, and page intent.
What stands out here is that the team did not treat SEO as a content-only discipline:
- settings for publication names
- schema output aligned with article types
- a sitemap index plus publication-level sitemap generation
- automatic
robots.txtcoverage
That combination is what makes technical SEO durable.
There is a larger lesson here for any content platform team. Search visibility is often improved less by dramatic redesigns and more by clean, dependable systems that help search engines understand what your publishing operation is actually doing. News content is time-sensitive, publisher-specific, and metadata-heavy. If your CMS does not express that clearly, your editorial team is doing the right work on a platform that is underselling it.
They turn the CMS into a better publishing citizen: one that knows what its news content is, who publishes it, where it lives, and how to expose it cleanly to downstream search systems.
That is technical SEO worth shipping.
Full implementation appendix
The full implementation code is below. I included the meaningful feature code that shipped with the Google News rollout and skipped whitespace-only diff noise.
Publisher settings
[Display(Name = "QUToday Publisher",
Description = "Default publisher for QUToday",
GroupName = CmsTabNames.ArticleSettings,
Order = 550)]
public virtual string QUTodayPublisher { get; set; }
[Display(Name = "QUMagazine Publisher",
Description = "Default publisher for QUMagazine",
GroupName = CmsTabNames.ArticleSettings,
Order = 560)]
public virtual string QUMagazinePublisher { get; set; }
Author metadata support
var metaAuthor = TryGetArticlePageMetaAuthor(sitePageData);
if (!string.IsNullOrWhiteSpace(metaAuthor))
{
output.AppendLine(string.Format(_metaFormat, "author", HttpUtility.HtmlEncode(metaAuthor)));
}
private static string TryGetArticlePageMetaAuthor(FoundationPageData page)
{
return page switch
{
ArticleDetailPage article => ResolveArticleAuthor(article.AuthorProfile, article.AuthorName),
MagazineArticleDetailPage magazine => ResolveArticleAuthor(magazine.AuthorProfile, magazine.AuthorName),
_ => null
};
}
private static string ResolveArticleAuthor(ContentReference authorProfile, string authorName)
{
if (authorProfile != null)
{
var profile = authorProfile.GetContent<ProfileDetailPage>();
var fullName = profile?.ProfileOverview?.FullName;
if (!string.IsNullOrWhiteSpace(fullName))
{
return fullName.Trim();
}
}
return string.IsNullOrWhiteSpace(authorName) ? null : authorName.Trim();
}
Open Graph author support
case ArticleDetailPage articleDetailPage:
var openGraphNewsArticle = new OpenGraphFoundationPageData(metaTitle, new OpenGraphImage(new Uri(imageUrl)), GetUrl(articleDetailPage.ContentLink))
{
Description = articleDetailPage.PageDescription,
Locale = defaultLocale.Name.Replace('-', '_'),
AlternateLocales = alternateLocales,
ContentType = contentType,
Category = articleDetailPage.Categories?.Select(c => c.ToString()),
ModifiedTime = articleDetailPage.Changed,
PublishedTime = articleDetailPage.StartPublish,
ExpirationTime = articleDetailPage.StopPublish,
Author = GetOpenGraphArticleAuthor(articleDetailPage.AuthorProfile, articleDetailPage.AuthorName)
};
return helper.OpenGraph(openGraphNewsArticle);
case MagazineArticleDetailPage magazineArticleDetailPage:
var openGraphMagazineArticle = new OpenGraphFoundationPageData(metaTitle, new OpenGraphImage(new Uri(imageUrl)), GetUrl(magazineArticleDetailPage.ContentLink))
{
Description = magazineArticleDetailPage.PageDescription,
Locale = defaultLocale.Name.Replace('-', '_'),
AlternateLocales = alternateLocales,
ContentType = contentType,
Category = magazineArticleDetailPage.Categories?.Select(c => c.ToString()),
ModifiedTime = magazineArticleDetailPage.Changed,
PublishedTime = magazineArticleDetailPage.StartPublish,
ExpirationTime = magazineArticleDetailPage.StopPublish,
Author = GetOpenGraphArticleAuthor(magazineArticleDetailPage.AuthorProfile, magazineArticleDetailPage.AuthorName)
};
return helper.OpenGraph(openGraphMagazineArticle);
private static string GetOpenGraphArticleAuthor(ContentReference authorProfile, string authorName)
{
string raw = null;
if (authorProfile != null)
{
var profile = authorProfile.GetContent<ProfileDetailPage>();
if (profile != null)
{
raw = profile.GetUri(true).ToString();
}
}
if (string.IsNullOrWhiteSpace(raw) && !string.IsNullOrWhiteSpace(authorName))
{
raw = authorName.Trim();
}
return string.IsNullOrEmpty(raw) ? null : WebUtility.HtmlEncode(raw);
}
stringBuilder.AppendMetaPropertyContentIfNotNull("article:author", Author);
Article schema mapper
using EPiServer.Web;
using EPiServer.Web.Routing;
using Cms.Features.Articles.Pages.ArticleDetail;
using Cms.Features.Articles.Pages.ExternalArticle;
using Cms.Features.Profiles.Pages.ProfileDetail;
using Cms.Features.Shared.Pages;
using Cms.Infrastructure.Extensions;
using Cms.Infrastructure.Helpers;
using Schema.NET;
namespace Cms.Infrastructure.SchemaMarkup
{
/// <summary>
/// Create Schema website and organization objects from ArticleDetailPage
/// </summary>
public class ArticleDetailPageSchemaMapper : ISchemaDataMapper<ArticleDetailPage>, ISchemaDataMapper<ExternalArticlePage>
{
public Thing Map(ArticleDetailPage content) => MapArticle(content);
Thing ISchemaDataMapper<ExternalArticlePage>.Map(ExternalArticlePage content) => MapArticle(content);
private Thing MapArticle(ArticleDetailPage content)
{
if (content.UseArticleSchema)
{
var articleSchema = new Article
{
Name = content.Name,
Headline = content.Heading,
Author = GetAuthor(content),
Publisher = GetPublisher(GetPublisherNameOverride(), GetPublisherLandingPage()),
ArticleSection = content.TopicsFacets != null ?
new OneOrMany<string>(content.TopicsFacets.Select(x => x.GetCategoryTitle())) :
new OneOrMany<string>(),
Description = content.Teaser,
DatePublished = content.PublishDate,
Url = content.GetUri(true),
Image = GetImage(content),
DateModified = content.PublishDate,
MainEntityOfPage = content.GetUri(true)
};
if (content.ArticleDetailMasthead.Image != null)
{
articleSchema.Image = new Uri(SiteDefinition.Current.SiteUrl, UrlResolver.Current.GetUrl(content.ArticleDetailMasthead.Image));
}
return articleSchema;
}
var eventSchema = new NewsArticle
{
Name = content.Name,
Headline = content.Heading,
Author = GetAuthor(content),
Publisher = GetPublisher(GetPublisherNameOverride(), GetPublisherLandingPage()),
ArticleSection = content.TopicsFacets != null ?
new OneOrMany<string>(content.TopicsFacets.Select(x => x.GetCategoryTitle())) :
new OneOrMany<string>(),
Description = content.Teaser,
DatePublished = content.PublishDate,
Url = content.GetUri(true),
Image = GetImage(content),
DateModified = content.PublishDate,
MainEntityOfPage = content.GetUri(true)
};
if (content.ArticleDetailMasthead.Image != null)
{
eventSchema.Image = new Uri(SiteDefinition.Current.SiteUrl, UrlResolver.Current.GetUrl(content.ArticleDetailMasthead.Image));
}
return eventSchema;
}
public Values<IOrganization, IPerson> GetAuthor(ArticleDetailPage content)
{
var author = new Person { Name = "Staff" };
if (content.AuthorProfile != null)
{
var profile = content.AuthorProfile.GetContent<ProfileDetailPage>();
var person = new Person
{
Name = profile.ProfileOverview.FullName,
Email = profile.ProfileOverview.DisplayEmailAddress ? profile.ProfileOverview.EmailAddress : string.Empty,
Url = profile.GetUri(true)
};
return new Values<IOrganization, IPerson>(null, person);
}
if (!string.IsNullOrEmpty(content.AuthorName))
{
author = new Person { Name = content.AuthorName };
}
return new Values<IOrganization, IPerson>(null, author);
}
private static string GetPublisherNameOverride()
{
var name = SettingsHelper.GetAlertsSettings()?.QUTodayPublisher;
return string.IsNullOrWhiteSpace(name) ? null : name.Trim();
}
private static ContentReference GetPublisherLandingPage()
{
return SettingsHelper.GetReferencePageSettings()?.ArticlesTodayLandingPage;
}
internal static Values<IOrganization, IPerson> GetPublisher(string publisherNameOverride, ContentReference publisherLandingPage)
{
var publisherPageName = !ContentReference.IsNullOrEmpty(publisherLandingPage)
? publisherLandingPage.GetContent<FoundationPageData>()?.Name
: null;
var headerSettings = SettingsHelper.GetHeaderSettings();
var footerName = SettingsHelper.GetFooterSettings().FooterCompanyInformation.CompanyName;
var publisher = new Organization
{
Name = !string.IsNullOrEmpty(publisherNameOverride)
? publisherNameOverride
: !string.IsNullOrWhiteSpace(publisherPageName)
? publisherPageName
: footerName,
Logo = new ImageObject
{
Url = new Uri(SiteDefinition.Current.SiteUrl, UrlResolver.Current.GetUrl(headerSettings.SiteLogo))
},
Url = !ContentReference.IsNullOrEmpty(publisherLandingPage)
? publisherLandingPage.GetUri(true)
: null
};
return new Values<IOrganization, IPerson>(publisher, null);
}
private Values<IImageObject, Uri> GetImage(ArticleDetailPage articleDetailPage)
{
var headerSettings = SettingsHelper.GetHeaderSettings();
var imageUrl = articleDetailPage.ThumbnailImage != null
? UrlResolver.Current.GetUrl(articleDetailPage.ThumbnailImage)
: UrlResolver.Current.GetUrl(headerSettings.SiteLogo);
var image = new ImageObject
{
Url = new Uri(SiteDefinition.Current.SiteUrl, imageUrl)
};
return new Values<IImageObject, Uri>(image, null);
}
}
}
Magazine schema mapper
using EPiServer.Web;
using EPiServer.Web.Routing;
using Cms.Features.Magazine.Pages.MagazineArticleDetail;
using Cms.Features.Magazine.Pages.MagazineExternalArticle;
using Cms.Features.Profiles.Pages.ProfileDetail;
using Cms.Features.Shared.Pages;
using Cms.Infrastructure.Extensions;
using Cms.Infrastructure.Helpers;
using Schema.NET;
namespace Cms.Infrastructure.SchemaMarkup
{
/// <summary>
/// JSON-LD for magazine articles, including publisher name for Google News.
/// </summary>
public class MagazineArticleDetailPageSchemaMapper :
ISchemaDataMapper<MagazineArticleDetailPage>,
ISchemaDataMapper<MagazineExternalArticlePage>
{
Thing ISchemaDataMapper<MagazineArticleDetailPage>.Map(MagazineArticleDetailPage content) => MapCore(content);
Thing ISchemaDataMapper<MagazineExternalArticlePage>.Map(MagazineExternalArticlePage content) => MapCore(content);
private static Thing MapCore(MagazineArticleDetailPage content)
{
if (content.UseArticleSchema)
{
var articleSchema = new Article
{
Name = content.Name,
Headline = content.Heading,
Author = GetAuthor(content),
Publisher = ArticleDetailPageSchemaMapper.GetPublisher(GetPublisherNameOverride(), GetPublisherLandingPage()),
ArticleSection = content.TopicsFacets != null
? new OneOrMany<string>(content.TopicsFacets.Select(x => x.GetCategoryTitle()))
: new OneOrMany<string>(),
Description = content.Teaser,
DatePublished = content.PublishDate,
Url = content.GetUri(true),
Image = GetImage(content),
DateModified = content.PublishDate,
MainEntityOfPage = content.GetUri(true)
};
if (content.MagazineArticleDetailMasthead?.Image != null)
{
articleSchema.Image = new Uri(SiteDefinition.Current.SiteUrl,
UrlResolver.Current.GetUrl(content.MagazineArticleDetailMasthead.Image));
}
return articleSchema;
}
var newsSchema = new NewsArticle
{
Name = content.Name,
Headline = content.Heading,
Author = GetAuthor(content),
Publisher = ArticleDetailPageSchemaMapper.GetPublisher(GetPublisherNameOverride(), GetPublisherLandingPage()),
ArticleSection = content.TopicsFacets != null
? new OneOrMany<string>(content.TopicsFacets.Select(x => x.GetCategoryTitle()))
: new OneOrMany<string>(),
Description = content.Teaser,
DatePublished = content.PublishDate,
Url = content.GetUri(true),
Image = GetImage(content),
DateModified = content.PublishDate,
MainEntityOfPage = content.GetUri(true)
};
if (content.MagazineArticleDetailMasthead?.Image != null)
{
newsSchema.Image = new Uri(SiteDefinition.Current.SiteUrl,
UrlResolver.Current.GetUrl(content.MagazineArticleDetailMasthead.Image));
}
return newsSchema;
}
private static string GetPublisherNameOverride()
{
var name = SettingsHelper.GetAlertsSettings()?.QUMagazinePublisher;
return string.IsNullOrWhiteSpace(name) ? null : name.Trim();
}
private static ContentReference GetPublisherLandingPage()
{
return SettingsHelper.GetReferencePageSettings()?.MagazineArticleLandingPage;
}
private static Values<IOrganization, IPerson> GetAuthor(MagazineArticleDetailPage content)
{
var author = new Person { Name = "Staff" };
if (content.AuthorProfile != null)
{
var profile = content.AuthorProfile.GetContent<ProfileDetailPage>();
var person = new Person
{
Name = profile.ProfileOverview.FullName,
Email = profile.ProfileOverview.DisplayEmailAddress ? profile.ProfileOverview.EmailAddress : string.Empty,
Url = profile.GetUri(true)
};
return new Values<IOrganization, IPerson>(null, person);
}
if (!string.IsNullOrEmpty(content.AuthorName))
{
author = new Person { Name = content.AuthorName };
}
return new Values<IOrganization, IPerson>(null, author);
}
private static Values<IImageObject, Uri> GetImage(MagazineArticleDetailPage articleDetailPage)
{
var headerSettings = SettingsHelper.GetHeaderSettings();
var imageUrl = articleDetailPage.ThumbnailImage != null
? UrlResolver.Current.GetUrl(articleDetailPage.ThumbnailImage)
: UrlResolver.Current.GetUrl(headerSettings.SiteLogo);
var image = new ImageObject
{
Url = new Uri(SiteDefinition.Current.SiteUrl, imageUrl)
};
return new Values<IImageObject, Uri>(image, null);
}
}
}
News sitemap contract and models
public interface INewsSitemapService
{
NewsSitemapPaths GetPaths();
string GenerateSitemapIndex();
string GeneratePublicationSitemap(NewsSitemapPublicationType publicationType);
}
public class NewsSitemapPaths
{
public string IndexPath { get; set; } = "/news-sitemap.xml";
public string ArticlesTodayPath { get; set; } = string.Empty;
public string QUMagazinePath { get; set; } = string.Empty;
}
public enum NewsSitemapPublicationType
{
ArticlesToday,
Magazine
}
News sitemap service
using System.Globalization;
using System.Xml.Linq;
using EPiServer.Web;
using Cms.Features.Articles.Pages.ArticleDetail;
using Cms.Features.Common.Interfaces;
using Cms.Features.Magazine.Pages.MagazineArticleDetail;
using Cms.Features.Settings;
using Cms.Features.Shared.Pages;
using Cms.Infrastructure.Extensions;
using Cms.Infrastructure.Settings;
namespace Cms.Infrastructure.Seo.NewsSitemaps
{
public class NewsSitemapService : INewsSitemapService
{
private const int MaxNewsMetadataUrls = 1000;
private static readonly TimeSpan FreshnessWindow = TimeSpan.FromDays(2);
private static readonly XNamespace SitemapNamespace = "http://www.sitemaps.org/schemas/sitemap/0.9";
private static readonly XNamespace NewsNamespace = "http://www.google.com/schemas/sitemap-news/0.9";
private readonly ISettingsService _settingsService;
private readonly IContentLoader _contentLoader;
public NewsSitemapService(ISettingsService settingsService, IContentLoader contentLoader)
{
_settingsService = settingsService;
_contentLoader = contentLoader;
}
public NewsSitemapPaths GetPaths()
{
var referenceSettings = _settingsService.GetSiteSettings<ReferencePageSettings>();
return new NewsSitemapPaths
{
IndexPath = "/news-sitemap.xml",
ArticlesTodayPath = BuildPublicationPath(referenceSettings?.ArticlesTodayLandingPage),
MagazinePath = BuildPublicationPath(referenceSettings?.MagazineArticleLandingPage)
};
}
public string GenerateSitemapIndex()
{
var publications = GetPublicationDefinitions();
var document = new XDocument(
new XDeclaration("1.0", "utf-8", "yes"),
new XElement(
SitemapNamespace + "sitemapindex",
publications
.Where(x => !string.IsNullOrWhiteSpace(x.Path))
.Select(x => new XElement(
SitemapNamespace + "sitemap",
new XElement(SitemapNamespace + "loc", x.Url),
new XElement(SitemapNamespace + "lastmod", FormatDateTime(x.LastModified))))));
return document.ToString();
}
public string GeneratePublicationSitemap(NewsSitemapPublicationType publicationType)
{
var publication = GetPublicationDefinitions()
.FirstOrDefault(x => x.PublicationType == publicationType);
var document = new XDocument(
new XDeclaration("1.0", "utf-8", "yes"),
new XElement(
SitemapNamespace + "urlset",
new XAttribute(XNamespace.Xmlns + "news", NewsNamespace),
publication?.Items.Select(CreateUrlElement)));
return document.ToString();
}
private List<PublicationDefinition> GetPublicationDefinitions()
{
var referenceSettings = _settingsService.GetSiteSettings<ReferencePageSettings>();
var alertSettings = _settingsService.GetSiteSettings<AlertSettings>();
Func<ArticleDetailPage, bool> quTodayPredicate = page => IsExactContentType<ArticleDetailPage>(page) && !page.UseArticleSchema;
Func<MagazineArticleDetailPage, bool> quMagazinePredicate = page => IsExactContentType<MagazineArticleDetailPage>(page) && !page.UseArticleSchema;
var articlesItems = GetPublicationItems<ArticleDetailPage>(
GetPublicationRootLink(
referenceSettings?.ArticleListingPage,
referenceSettings?.ArticlesTodayLandingPage,
quTodayPredicate),
GetPublicationName(alertSettings?.QUTodayPublisher, referenceSettings?.ArtcilesTodayLandingPage, "Articles Today"),
quTodayPredicate);
var quMagazineItems = GetPublicationItems<MagazineArticleDetailPage>(
GetPublicationRootLink(
referenceSettings?.MagazineArticleListingPage,
referenceSettings?.MagazineArticleLandingPage,
quMagazinePredicate),
GetPublicationName(alertSettings?.QUMagazinePublisher, referenceSettings?.MagazineArticleLandingPage, "QU Magazine"),
quMagazinePredicate);
return new List<PublicationDefinition>
{
CreatePublicationDefinition(
NewsSitemapPublicationType.ArticlesToday,
referenceSettings?.ArticlesTodayLandingPage,
ArticlesTodayItems),
CreatePublicationDefinition(
NewsSitemapPublicationType.QUMagazine,
referenceSettings?.MagazineArticleLandingPage,
quMagazineItems)
};
}
private PublicationDefinition CreatePublicationDefinition(
NewsSitemapPublicationType publicationType,
ContentReference landingPageReference,
List<NewsSitemapItem> items)
{
var path = BuildPublicationPath(landingPageReference);
var url = BuildAbsoluteUrl(path);
var lastModified = items.Any()
? items.Max(x => x.LastModified)
: DateTime.Now;
return new PublicationDefinition(publicationType, path, url, lastModified, items);
}
private List<NewsSitemapItem> GetPublicationItems<TPage>(
ContentReference rootLink,
string publicationName,
Func<TPage, bool> includePage) where TPage : FoundationPageData, IQuTodayArticleContent
{
if (ContentReference.IsNullOrEmpty(rootLink))
{
return new List<NewsSitemapItem>();
}
var publishDateCutoff = DateTime.Now.Subtract(FreshnessWindow);
var items = rootLink.GetAll<TPage>()
.Where(page => page != null)
.Where(includePage)
.Where(page => !page.DisableIndexing && !page.ExcludeFromSiteMap)
.Where(page => page.GetPublishDate() != DateTime.MinValue)
.Select(page => new NewsSitemapItem(
page.ContentLink.GetUri(true),
GetPageTitle(page),
publicationName,
GetPublicationLanguage(page),
page.GetPublishDate(),
page.Changed != DateTime.MinValue ? page.Changed : page.GetPublishDate(),
false))
.Where(item => item.Url != null && !string.IsNullOrWhiteSpace(item.Url.ToString()))
.OrderByDescending(item => item.PublishDate)
.ToList();
var recentNewsMetadataCount = 0;
for (var i = 0; i < items.Count; i++)
{
if (items[i].PublishDate < publishDateCutoff || recentNewsMetadataCount >= MaxNewsMetadataUrls)
{
continue;
}
items[i] = items[i] with { IncludeNewsMetadata = true };
recentNewsMetadataCount++;
}
return items;
}
private ContentReference GetPublicationRootLink<TPage>(
ContentReference preferredRootLink,
ContentReference fallbackRootLink,
Func<TPage, bool> includePage) where TPage : FoundationPageData, IQuTodayArticleContent
{
if (HasPublicationItems(preferredRootLink, includePage))
{
return preferredRootLink;
}
if (HasPublicationItems(fallbackRootLink, includePage))
{
return fallbackRootLink;
}
return preferredRootLink ?? fallbackRootLink;
}
private bool HasPublicationItems<TPage>(
ContentReference rootLink,
Func<TPage, bool> includePage) where TPage : FoundationPageData, IQuTodayArticleContent
{
if (ContentReference.IsNullOrEmpty(rootLink))
{
return false;
}
return rootLink.GetAll<TPage>().Any(includePage);
}
private XElement CreateUrlElement(NewsSitemapItem item)
{
var urlElement = new XElement(
SitemapNamespace + "url",
new XElement(SitemapNamespace + "loc", item.Url),
new XElement(SitemapNamespace + "lastmod", FormatDateTime(item.LastModified)));
if (item.IncludeNewsMetadata)
{
urlElement.Add(
new XElement(
NewsNamespace + "news",
new XElement(
NewsNamespace + "publication",
new XElement(NewsNamespace + "name", item.PublicationName),
new XElement(NewsNamespace + "language", item.Language)),
new XElement(NewsNamespace + "publication_date", FormatDateTime(item.PublishDate)),
new XElement(NewsNamespace + "title", item.Title)));
}
return urlElement;
}
private string GetPublicationName(string publisherOverride, ContentReference landingPageReference, string fallbackName)
{
if (!string.IsNullOrWhiteSpace(publisherOverride))
{
return publisherOverride.Trim();
}
if (!ContentReference.IsNullOrEmpty(landingPageReference)
&& _contentLoader.TryGet(landingPageReference, out FoundationPageData landingPage)
&& !string.IsNullOrWhiteSpace(landingPage.Name))
{
return landingPage.Name.Trim();
}
return fallbackName;
}
private static string GetPageTitle(FoundationPageData page)
{
if (!string.IsNullOrWhiteSpace(page.Heading))
{
return page.Heading.Trim();
}
if (!string.IsNullOrWhiteSpace(page.MetaTitle))
{
return page.MetaTitle.Trim();
}
return page.Name;
}
private static bool IsExactContentType<TExpected>(IContent content) where TExpected : IContent
{
return content?.GetOriginalType() == typeof(TExpected);
}
private static string GetPublicationLanguage(FoundationPageData content)
{
var language = content?.Language?.Name;
if (string.IsNullOrWhiteSpace(language))
{
return "en";
}
try
{
return CultureInfo.GetCultureInfo(language).TwoLetterISOLanguageName;
}
catch (CultureNotFoundException)
{
return language.Split('-')[0].ToLowerInvariant();
}
}
private static string BuildPublicationPath(ContentReference landingPageReference)
{
if (ContentReference.IsNullOrEmpty(landingPageReference))
{
return string.Empty;
}
var uri = landingPageReference.GetUri(true);
var absolutePath = uri.IsAbsoluteUri
? uri.AbsolutePath
: uri.ToString();
return $"{absolutePath.TrimEnd('/')}/news-sitemap.xml";
}
private static Uri BuildAbsoluteUrl(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
return new Uri(SiteDefinition.Current.SiteUrl, path.TrimStart('/'));
}
private static string FormatDateTime(DateTime dateTime)
{
var value = dateTime.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(dateTime, DateTimeKind.Local)
: dateTime;
return value.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture);
}
private record NewsSitemapItem(
Uri Url,
string Title,
string PublicationName,
string Language,
DateTime PublishDate,
DateTime LastModified,
bool IncludeNewsMetadata);
private record PublicationDefinition(
NewsSitemapPublicationType PublicationType,
string Path,
Uri Url,
DateTime LastModified,
List<NewsSitemapItem> Items);
}
}
Middleware and registrations
using System.Text;
using Microsoft.AspNetCore.Http.Extensions;
using Cms.Infrastructure.Seo.NewsSitemaps;
namespace Cms.Infrastructure.Middlewares
{
public class NewsSitemapMiddleware
{
private readonly RequestDelegate _next;
public NewsSitemapMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, INewsSitemapService newsSitemapService)
{
if (!HttpMethods.IsGet(context.Request.Method))
{
await _next(context);
return;
}
var requestPath = NormalizePath(context.Request.Path.Value);
var paths = newsSitemapService.GetPaths();
string xml = null;
if (string.Equals(requestPath, "/robots.txt", StringComparison.InvariantCultureIgnoreCase))
{
await AppendNewsSitemapToRobotsAsync(context, paths.IndexPath);
return;
}
if (string.Equals(requestPath, NormalizePath(paths.IndexPath), StringComparison.InvariantCultureIgnoreCase))
{
xml = newsSitemapService.GenerateSitemapIndex();
}
else if (string.Equals(requestPath, NormalizePath(paths.ArticlesTodayPath), StringComparison.InvariantCultureIgnoreCase))
{
xml = newsSitemapService.GeneratePublicationSitemap(NewsSitemapPublicationType.ArticlesToday);
}
else if (string.Equals(requestPath, NormalizePath(paths.QUMagazinePath), StringComparison.InvariantCultureIgnoreCase))
{
xml = newsSitemapService.GeneratePublicationSitemap(NewsSitemapPublicationType.QUMagazine);
}
if (xml == null)
{
await _next(context);
return;
}
context.Response.ContentType = "application/xml; charset=utf-8";
await context.Response.WriteAsync(xml);
}
private async Task AppendNewsSitemapToRobotsAsync(HttpContext context, string indexPath)
{
var originalBody = context.Response.Body;
await using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
try
{
await _next(context);
responseBody.Position = 0;
using var reader = new StreamReader(responseBody, Encoding.UTF8, leaveOpen: true);
var robotsContent = await reader.ReadToEndAsync();
context.Response.Body = originalBody;
responseBody.Position = 0;
if (context.Response.StatusCode != StatusCodes.Status200OK || string.IsNullOrWhiteSpace(robotsContent))
{
await responseBody.CopyToAsync(originalBody);
return;
}
var newsSitemapUrl = UriHelper.BuildAbsolute(
context.Request.Scheme,
context.Request.Host,
context.Request.PathBase,
NormalizePath(indexPath));
if (!robotsContent.Contains(newsSitemapUrl, StringComparison.InvariantCultureIgnoreCase))
{
var separator = robotsContent.EndsWith(Environment.NewLine, StringComparison.Ordinal)
? string.Empty
: Environment.NewLine;
robotsContent = $"{robotsContent}{separator}Sitemap: {newsSitemapUrl}{Environment.NewLine}";
context.Response.ContentLength = Encoding.UTF8.GetByteCount(robotsContent);
}
await context.Response.WriteAsync(robotsContent);
}
finally
{
context.Response.Body = originalBody;
}
}
private static string NormalizePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "/";
}
return path.Length > 1
? path.TrimEnd('/')
: path;
}
}
}
context.Services.AddSingleton<ArticleDetailPageSchemaMapper>();
context.Services.AddSingleton<ISchemaDataMapper<ArticleDetailPage>>(sp =>
sp.GetRequiredService<ArticleDetailPageSchemaMapper>());
context.Services.AddSingleton<ISchemaDataMapper<ExternalArticlePage>>(sp =>
sp.GetRequiredService<ArticleDetailPageSchemaMapper>());
context.Services.AddSingleton<MagazineArticleDetailPageSchemaMapper>();
context.Services.AddSingleton<ISchemaDataMapper<MagazineArticleDetailPage>>(sp =>
sp.GetRequiredService<MagazineArticleDetailPageSchemaMapper>());
context.Services.AddSingleton<ISchemaDataMapper<MagazineExternalArticlePage>>(sp =>
sp.GetRequiredService<MagazineArticleDetailPageSchemaMapper>());
context.Services.AddTransient<INewsSitemapService, NewsSitemapService>();
app.UseMiddleware<NewsSitemapMiddleware>();