From 9afa6c7a5ab2b2351b213629fba8aff8538dda15 Mon Sep 17 00:00:00 2001 From: Jan Nielsen Date: Wed, 16 Jul 2025 18:15:24 +0200 Subject: [PATCH 1/3] Add support for language exclusion in sitemap.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the ability to exclude specific languages from sitemap.xml generation. ### What’s new: - New property `DisallowLanguages` in `SitemapXmlSettings` - `SitemapModelFactory` and alternate URL logic now respects this setting - Default behavior remains unchanged (no exclusions) ### Why: In some scenarios, site owners may want to: - Prevent certain language versions from being listed in sitemaps (e.g. test/staging content) - Avoid conflicting SEO signals when the same languages are excluded in `robots.txt` This setting provides fine-grained control and separates concerns between `robots.txt` and sitemap configuration. Closes #7777 --- src/Libraries/Nop.Core/Domain/Common/SitemapXmlSettings.cs | 5 +++++ src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Nop.Core/Domain/Common/SitemapXmlSettings.cs b/src/Libraries/Nop.Core/Domain/Common/SitemapXmlSettings.cs index 2e3ae9c88b9..ec4a7548b30 100644 --- a/src/Libraries/Nop.Core/Domain/Common/SitemapXmlSettings.cs +++ b/src/Libraries/Nop.Core/Domain/Common/SitemapXmlSettings.cs @@ -71,4 +71,9 @@ public SitemapXmlSettings() /// Gets or sets the wait time (in seconds) before the operation can be started again /// public int SitemapBuildOperationDelay { get; set; } + + /// + /// Disallow languages + /// + public List DisallowLanguages { get; set; } = new(); } \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs b/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs index c9b225bb097..9f8729f80dd 100644 --- a/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs +++ b/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs @@ -887,7 +887,8 @@ var name when name.Equals(nameof(ProductTag), StringComparison.InvariantCultureI var updatedOn = dateTimeUpdatedOn ?? DateTime.UtcNow; var languages = _localizationSettings.SeoFriendlyUrlsForLanguagesEnabled - ? await _languageService.GetAllLanguagesAsync(storeId: store.Id) + ? (await _languageService.GetAllLanguagesAsync(storeId: store.Id)) + .Where(lang => !_sitemapXmlSettings.DisallowLanguages.Contains(lang.Id)).ToList() : null; if (languages == null || languages.Count == 1) From c22f0392a840a3f5c49cd60a35a2ad3595b1d9db Mon Sep 17 00:00:00 2001 From: Jan Nielsen Date: Thu, 17 Jul 2025 00:45:37 +0200 Subject: [PATCH 2/3] fix current language selection if is not allowed for sitemap --- .../Nop.Core/Http/NopHttpDefaults.cs | 5 ++ .../Routing/LanguageParameterTransformer.cs | 7 +- .../Nop.Web/Factories/SitemapModelFactory.cs | 67 ++++++++++++++----- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/Libraries/Nop.Core/Http/NopHttpDefaults.cs b/src/Libraries/Nop.Core/Http/NopHttpDefaults.cs index eab7a245a95..df318e139aa 100644 --- a/src/Libraries/Nop.Core/Http/NopHttpDefaults.cs +++ b/src/Libraries/Nop.Core/Http/NopHttpDefaults.cs @@ -19,4 +19,9 @@ public static partial class NopHttpDefaults /// Gets the name of a request item that stores the value that indicates whether the request is being redirected by the generic route transformer /// public static string GenericRouteInternalRedirect => "nop.RedirectFromGenericPathRoute"; + + /// + /// Gets the name of a request item that stores the value with default language for sitemap.xml + /// + public static string ForcedSitemapXmlLanguage => "nop.ForcedSitemapXmlLanguage"; } \ No newline at end of file diff --git a/src/Presentation/Nop.Web.Framework/Mvc/Routing/LanguageParameterTransformer.cs b/src/Presentation/Nop.Web.Framework/Mvc/Routing/LanguageParameterTransformer.cs index 4a193bdae73..6092c42caa9 100644 --- a/src/Presentation/Nop.Web.Framework/Mvc/Routing/LanguageParameterTransformer.cs +++ b/src/Presentation/Nop.Web.Framework/Mvc/Routing/LanguageParameterTransformer.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Nop.Core; +using Nop.Core.Http; using Nop.Core.Infrastructure; using Nop.Services.Localization; @@ -38,7 +39,11 @@ public LanguageParameterTransformer(IHttpContextAccessor httpContextAccessor, /// The transformed value public string TransformOutbound(object value) { - //first try to get a language code from the route values + // first check if we have forced value for sitemap.xml + if (_httpContextAccessor.HttpContext?.Items[NopHttpDefaults.ForcedSitemapXmlLanguage] is string forcedLang && !string.IsNullOrEmpty(forcedLang)) + return forcedLang; + + // then try to get a language code from the route values var routeValues = _httpContextAccessor.HttpContext.Request.RouteValues; if (routeValues.TryGetValue(NopRoutingDefaults.RouteValue.Language, out var routeValue)) { diff --git a/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs b/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs index 9f8729f80dd..e1124564404 100644 --- a/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs +++ b/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs @@ -13,8 +13,10 @@ using Nop.Core.Domain.Localization; using Nop.Core.Domain.News; using Nop.Core.Domain.Seo; +using Nop.Core.Domain.Stores; using Nop.Core.Domain.Topics; using Nop.Core.Events; +using Nop.Core.Http; using Nop.Core.Infrastructure; using Nop.Services.Blogs; using Nop.Services.Catalog; @@ -522,7 +524,7 @@ protected virtual async Task WriteSitemapUrlAsync(XmlWriter writer, SitemapUrlMo /// The path and name of the sitemap file /// Sitemap identifier /// A task that represents the asynchronous operation - protected virtual async Task GenerateAsync(string fullPath, int id = 0) + protected virtual async Task GenerateAsync(string fullPath, int id = 0) { //generate all URLs for the sitemap var sitemapUrls = await GenerateUrlsAsync(); @@ -603,6 +605,22 @@ protected string GetLocalizedUrl(string currentUrl, Language lang) return new Uri(new Uri(scheme), localizedPath).ToString(); } + /// + /// Retrieves the list of languages for the given store that are not excluded in the sitemap XML settings, + /// if SEO-friendly URLs for languages are enabled. + /// + /// The store to retrieve allowed languages for. + /// + /// A list of if SEO-friendly URLs are enabled; otherwise, null. + /// + protected async Task> GetAllowedLanguagesAsync(Store store) + { + return _localizationSettings.SeoFriendlyUrlsForLanguagesEnabled + ? (await _languageService.GetAllLanguagesAsync(storeId: store.Id)) + .Where(lang => !_sitemapXmlSettings.DisallowLanguages.Contains(lang.Id)) + .ToList() + : null; + } #endregion #region Methods @@ -823,27 +841,43 @@ public virtual async Task PrepareSitemapModelAsync(SitemapPageMode /// public virtual async Task PrepareSitemapXmlModelAsync(int id = 0) { - var language = await _workContext.GetWorkingLanguageAsync(); + var workingLanguage = await _workContext.GetWorkingLanguageAsync(); var store = await _storeContext.GetCurrentStoreAsync(); + + // get list of allowed languages (null if multilingual URLs are disabled) + var languages = await GetAllowedLanguagesAsync(store); - var fileName = string.Format(NopSeoDefaults.SitemapXmlFilePattern, store.Id, language.Id, id); - var fullPath = _nopFileProvider.GetAbsolutePath(NopSeoDefaults.SitemapXmlDirectory, fileName); + // select current language if allowed, fallback to first allowed if needed + var language = languages?.FirstOrDefault(lang => lang.Id == workingLanguage?.Id) ?? languages?.FirstOrDefault() ?? workingLanguage; - if (_nopFileProvider.FileExists(fullPath) && _nopFileProvider.GetLastWriteTimeUtc(fullPath) > DateTime.UtcNow.AddHours(-_sitemapXmlSettings.RebuildSitemapXmlAfterHours)) + if (language.Id != workingLanguage.Id) + _actionContextAccessor.ActionContext.HttpContext.Items[NopHttpDefaults.ForcedSitemapXmlLanguage] = language.UniqueSeoCode.ToLowerInvariant(); + + try { + var fileName = string.Format(NopSeoDefaults.SitemapXmlFilePattern, store.Id, language.Id, id); + var fullPath = _nopFileProvider.GetAbsolutePath(NopSeoDefaults.SitemapXmlDirectory, fileName); + + if (_nopFileProvider.FileExists(fullPath) && _nopFileProvider.GetLastWriteTimeUtc(fullPath) > DateTime.UtcNow.AddHours(-_sitemapXmlSettings.RebuildSitemapXmlAfterHours)) + { + return new SitemapXmlModel { SitemapXmlPath = fullPath }; + } + + //execute task with lock + if (!await _locker.PerformActionWithLockAsync( + fullPath, + TimeSpan.FromSeconds(_sitemapXmlSettings.SitemapBuildOperationDelay), + async () => await GenerateAsync(fullPath, id))) + { + throw new InvalidOperationException(); + } + return new SitemapXmlModel { SitemapXmlPath = fullPath }; } - - //execute task with lock - if (!await _locker.PerformActionWithLockAsync( - fullPath, - TimeSpan.FromSeconds(_sitemapXmlSettings.SitemapBuildOperationDelay), - async () => await GenerateAsync(fullPath, id))) + finally { - throw new InvalidOperationException(); + _actionContextAccessor.ActionContext.HttpContext.Items.Remove(NopHttpDefaults.ForcedSitemapXmlLanguage); } - - return new SitemapXmlModel { SitemapXmlPath = fullPath }; } /// @@ -886,10 +920,7 @@ var name when name.Equals(nameof(ProductTag), StringComparison.InvariantCultureI var store = await _storeContext.GetCurrentStoreAsync(); var updatedOn = dateTimeUpdatedOn ?? DateTime.UtcNow; - var languages = _localizationSettings.SeoFriendlyUrlsForLanguagesEnabled - ? (await _languageService.GetAllLanguagesAsync(storeId: store.Id)) - .Where(lang => !_sitemapXmlSettings.DisallowLanguages.Contains(lang.Id)).ToList() - : null; + var languages = await GetAllowedLanguagesAsync(store); if (languages == null || languages.Count == 1) return new SitemapUrlModel(url, new List(), updateFreq, updatedOn); From 052034e9a37d2d68f363546712f3b9df06cce10c Mon Sep 17 00:00:00 2001 From: Jan Nielsen Date: Thu, 17 Jul 2025 17:59:11 +0200 Subject: [PATCH 3/3] revert weird approach and set default to store default language --- .../Nop.Core/Http/NopHttpDefaults.cs | 5 -- .../Routing/LanguageParameterTransformer.cs | 7 +- .../Nop.Web/Factories/SitemapModelFactory.cs | 86 +++++++------------ 3 files changed, 32 insertions(+), 66 deletions(-) diff --git a/src/Libraries/Nop.Core/Http/NopHttpDefaults.cs b/src/Libraries/Nop.Core/Http/NopHttpDefaults.cs index df318e139aa..eab7a245a95 100644 --- a/src/Libraries/Nop.Core/Http/NopHttpDefaults.cs +++ b/src/Libraries/Nop.Core/Http/NopHttpDefaults.cs @@ -19,9 +19,4 @@ public static partial class NopHttpDefaults /// Gets the name of a request item that stores the value that indicates whether the request is being redirected by the generic route transformer /// public static string GenericRouteInternalRedirect => "nop.RedirectFromGenericPathRoute"; - - /// - /// Gets the name of a request item that stores the value with default language for sitemap.xml - /// - public static string ForcedSitemapXmlLanguage => "nop.ForcedSitemapXmlLanguage"; } \ No newline at end of file diff --git a/src/Presentation/Nop.Web.Framework/Mvc/Routing/LanguageParameterTransformer.cs b/src/Presentation/Nop.Web.Framework/Mvc/Routing/LanguageParameterTransformer.cs index 6092c42caa9..4a193bdae73 100644 --- a/src/Presentation/Nop.Web.Framework/Mvc/Routing/LanguageParameterTransformer.cs +++ b/src/Presentation/Nop.Web.Framework/Mvc/Routing/LanguageParameterTransformer.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Nop.Core; -using Nop.Core.Http; using Nop.Core.Infrastructure; using Nop.Services.Localization; @@ -39,11 +38,7 @@ public LanguageParameterTransformer(IHttpContextAccessor httpContextAccessor, /// The transformed value public string TransformOutbound(object value) { - // first check if we have forced value for sitemap.xml - if (_httpContextAccessor.HttpContext?.Items[NopHttpDefaults.ForcedSitemapXmlLanguage] is string forcedLang && !string.IsNullOrEmpty(forcedLang)) - return forcedLang; - - // then try to get a language code from the route values + //first try to get a language code from the route values var routeValues = _httpContextAccessor.HttpContext.Request.RouteValues; if (routeValues.TryGetValue(NopRoutingDefaults.RouteValue.Language, out var routeValue)) { diff --git a/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs b/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs index e1124564404..d57ad4eb3e6 100644 --- a/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs +++ b/src/Presentation/Nop.Web/Factories/SitemapModelFactory.cs @@ -13,10 +13,8 @@ using Nop.Core.Domain.Localization; using Nop.Core.Domain.News; using Nop.Core.Domain.Seo; -using Nop.Core.Domain.Stores; using Nop.Core.Domain.Topics; using Nop.Core.Events; -using Nop.Core.Http; using Nop.Core.Infrastructure; using Nop.Services.Blogs; using Nop.Services.Catalog; @@ -524,7 +522,7 @@ protected virtual async Task WriteSitemapUrlAsync(XmlWriter writer, SitemapUrlMo /// The path and name of the sitemap file /// Sitemap identifier /// A task that represents the asynchronous operation - protected virtual async Task GenerateAsync(string fullPath, int id = 0) + protected virtual async Task GenerateAsync(string fullPath, int id = 0) { //generate all URLs for the sitemap var sitemapUrls = await GenerateUrlsAsync(); @@ -605,22 +603,6 @@ protected string GetLocalizedUrl(string currentUrl, Language lang) return new Uri(new Uri(scheme), localizedPath).ToString(); } - /// - /// Retrieves the list of languages for the given store that are not excluded in the sitemap XML settings, - /// if SEO-friendly URLs for languages are enabled. - /// - /// The store to retrieve allowed languages for. - /// - /// A list of if SEO-friendly URLs are enabled; otherwise, null. - /// - protected async Task> GetAllowedLanguagesAsync(Store store) - { - return _localizationSettings.SeoFriendlyUrlsForLanguagesEnabled - ? (await _languageService.GetAllLanguagesAsync(storeId: store.Id)) - .Where(lang => !_sitemapXmlSettings.DisallowLanguages.Contains(lang.Id)) - .ToList() - : null; - } #endregion #region Methods @@ -841,43 +823,27 @@ public virtual async Task PrepareSitemapModelAsync(SitemapPageMode /// public virtual async Task PrepareSitemapXmlModelAsync(int id = 0) { - var workingLanguage = await _workContext.GetWorkingLanguageAsync(); + var language = await _workContext.GetWorkingLanguageAsync(); var store = await _storeContext.GetCurrentStoreAsync(); - - // get list of allowed languages (null if multilingual URLs are disabled) - var languages = await GetAllowedLanguagesAsync(store); - // select current language if allowed, fallback to first allowed if needed - var language = languages?.FirstOrDefault(lang => lang.Id == workingLanguage?.Id) ?? languages?.FirstOrDefault() ?? workingLanguage; + var fileName = string.Format(NopSeoDefaults.SitemapXmlFilePattern, store.Id, language.Id, id); + var fullPath = _nopFileProvider.GetAbsolutePath(NopSeoDefaults.SitemapXmlDirectory, fileName); - if (language.Id != workingLanguage.Id) - _actionContextAccessor.ActionContext.HttpContext.Items[NopHttpDefaults.ForcedSitemapXmlLanguage] = language.UniqueSeoCode.ToLowerInvariant(); - - try + if (_nopFileProvider.FileExists(fullPath) && _nopFileProvider.GetLastWriteTimeUtc(fullPath) > DateTime.UtcNow.AddHours(-_sitemapXmlSettings.RebuildSitemapXmlAfterHours)) { - var fileName = string.Format(NopSeoDefaults.SitemapXmlFilePattern, store.Id, language.Id, id); - var fullPath = _nopFileProvider.GetAbsolutePath(NopSeoDefaults.SitemapXmlDirectory, fileName); - - if (_nopFileProvider.FileExists(fullPath) && _nopFileProvider.GetLastWriteTimeUtc(fullPath) > DateTime.UtcNow.AddHours(-_sitemapXmlSettings.RebuildSitemapXmlAfterHours)) - { - return new SitemapXmlModel { SitemapXmlPath = fullPath }; - } - - //execute task with lock - if (!await _locker.PerformActionWithLockAsync( - fullPath, - TimeSpan.FromSeconds(_sitemapXmlSettings.SitemapBuildOperationDelay), - async () => await GenerateAsync(fullPath, id))) - { - throw new InvalidOperationException(); - } - return new SitemapXmlModel { SitemapXmlPath = fullPath }; } - finally + + //execute task with lock + if (!await _locker.PerformActionWithLockAsync( + fullPath, + TimeSpan.FromSeconds(_sitemapXmlSettings.SitemapBuildOperationDelay), + async () => await GenerateAsync(fullPath, id))) { - _actionContextAccessor.ActionContext.HttpContext.Items.Remove(NopHttpDefaults.ForcedSitemapXmlLanguage); + throw new InvalidOperationException(); } + + return new SitemapXmlModel { SitemapXmlPath = fullPath }; } /// @@ -912,16 +878,26 @@ var name when name.Equals(nameof(ProductTag), StringComparison.InvariantCultureI _ => GetUrlHelper().RouteUrl(routeName, values, protocol) }; - //url for current language - var url = await routeUrlAsync(routeName, - getRouteParamsAwait != null ? await getRouteParamsAwait(null) : null, - await GetHttpProtocolAsync()); - var store = await _storeContext.GetCurrentStoreAsync(); var updatedOn = dateTimeUpdatedOn ?? DateTime.UtcNow; - var languages = await GetAllowedLanguagesAsync(store); + var languages = _localizationSettings.SeoFriendlyUrlsForLanguagesEnabled + ? (await _languageService.GetAllLanguagesAsync(storeId: store.Id)) + .Where(lang => !_sitemapXmlSettings.DisallowLanguages.Contains(lang.Id)).ToList() + : null; + + // select store default language if allowed, fallback to first allowed if needed + var workingLanguage = await _workContext.GetWorkingLanguageAsync(); + var language = languages?.FirstOrDefault(lang => lang.Id == store.DefaultLanguageId) ?? languages?.FirstOrDefault() ?? workingLanguage; + //url for current language + var url = await routeUrlAsync(routeName, + getRouteParamsAwait != null ? await getRouteParamsAwait(language.Id) : null, + await GetHttpProtocolAsync()); + + if (language.Id != workingLanguage.Id) + url = GetLocalizedUrl(url, language); + if (languages == null || languages.Count == 1) return new SitemapUrlModel(url, new List(), updateFreq, updatedOn); @@ -933,7 +909,7 @@ var name when name.Equals(nameof(ProductTag), StringComparison.InvariantCultureI getRouteParamsAwait != null ? await getRouteParamsAwait(lang.Id) : null, await GetHttpProtocolAsync()); - return GetLocalizedUrl(currentUrl, lang); + return lang.Id != workingLanguage.Id ? GetLocalizedUrl(currentUrl, lang) : currentUrl; }) .Where(value => !string.IsNullOrEmpty(value)) .ToListAsync();