diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs
index 974f93373bb7..831086e2017c 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs
@@ -37,15 +37,26 @@ public DocumentUrlController(
///
/// A set of document IDs for which to retrieve URLs.
/// A task representing the asynchronous operation. The task result contains an with a collection of URL information for each requested document.
+ [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 19.")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task GetUrls([FromQuery(Name = "id")] HashSet ids)
+ => GetUrls(ids, null);
+
+ ///
+ /// Retrieves the URLs for the documents identified by the specified set of IDs.
+ ///
+ /// A set of document IDs for which to retrieve URLs.
+ /// An optional culture to restrict variant document URLs to. When omitted, URLs for all cultures are returned.
+ /// A task representing the asynchronous operation. The task result contains an with a collection of URL information for each requested document.
[MapToApiVersion("1.0")]
[HttpGet("urls")]
[ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
[EndpointSummary("Gets URLs for a document.")]
[EndpointDescription("Gets the URLs for the document identified by the provided Id.")]
- public async Task GetUrls([FromQuery(Name = "id")] HashSet ids)
+ public async Task GetUrls([FromQuery(Name = "id")] HashSet ids, [FromQuery] string? culture)
{
IEnumerable items = _contentService.GetByIds(ids);
- return Ok(await _documentUrlFactory.CreateUrlSetsAsync(items));
+ return Ok(await _documentUrlFactory.CreateUrlSetsAsync(items, culture));
}
}
diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs
index d053e11e68b4..67d896d3d8c1 100644
--- a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs
+++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs
@@ -48,9 +48,18 @@ public DocumentUrlFactory(
///
/// The content item for which to generate URLs.
/// A task representing the asynchronous operation. The task result contains an with the generated URLs.
- public async Task> CreateUrlsAsync(IContent content)
+ public Task> CreateUrlsAsync(IContent content)
+ => CreateUrlsAsync(content, culture: null);
+
+ ///
+ /// Asynchronously generates a collection of instances representing the URLs for the specified content item, optionally restricted to a single culture.
+ ///
+ /// The content item for which to generate URLs.
+ /// The culture to restrict variant content urls to, or null for all cultures.
+ /// A task representing the asynchronous operation. The task result contains an with the generated URLs.
+ public async Task> CreateUrlsAsync(IContent content, string? culture)
{
- ISet urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content);
+ ISet urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content, culture);
return urlInfos
.Select(CreateDocumentUrlInfo)
.ToArray();
@@ -61,13 +70,22 @@ public async Task> CreateUrlsAsync(IContent content
///
/// The collection of items for which to generate URL sets.
/// A task representing the asynchronous operation. The task result contains a collection of objects, each representing the URL set for a content item.
- public async Task> CreateUrlSetsAsync(IEnumerable contentItems)
+ public Task> CreateUrlSetsAsync(IEnumerable contentItems)
+ => CreateUrlSetsAsync(contentItems, culture: null);
+
+ ///
+ /// Asynchronously creates URL sets for the specified collection of content items, optionally restricted to a single culture.
+ ///
+ /// The collection of items for which to generate URL sets.
+ /// The culture to restrict variant content urls to, or null for all cultures.
+ /// A task representing the asynchronous operation. The task result contains a collection of objects, each representing the URL set for a content item.
+ public async Task> CreateUrlSetsAsync(IEnumerable contentItems, string? culture)
{
var documentUrlInfoResourceSets = new List();
foreach (IContent content in contentItems)
{
- IEnumerable urls = await CreateUrlsAsync(content);
+ IEnumerable urls = await CreateUrlsAsync(content, culture);
documentUrlInfoResourceSets.Add(new DocumentUrlInfoResponseModel(content.Key, urls));
}
diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs
index 4e2fea671755..e4881b7bee34 100644
--- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs
+++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs
@@ -15,6 +15,16 @@ public interface IDocumentUrlFactory
/// A task that represents the asynchronous operation. The task result contains a collection of DocumentUrlInfo.
Task> CreateUrlsAsync(IContent content);
+ ///
+ /// Creates URLs asynchronously for the given content, optionally restricted to a single culture.
+ ///
+ /// The content to create URLs for.
+ /// The culture to restrict variant content urls to, or null for all cultures.
+ /// A task that represents the asynchronous operation. The task result contains a collection of DocumentUrlInfo.
+ // TODO (V19): Remove the default implementation.
+ Task> CreateUrlsAsync(IContent content, string? culture)
+ => CreateUrlsAsync(content);
+
///
/// Asynchronously creates URL sets for the specified content items.
///
@@ -22,6 +32,16 @@ public interface IDocumentUrlFactory
/// A task representing the asynchronous operation, with a result containing an enumerable of representing the URL sets.
Task> CreateUrlSetsAsync(IEnumerable contentItems);
+ ///
+ /// Asynchronously creates URL sets for the specified content items, optionally restricted to a single culture.
+ ///
+ /// The content items for which to generate URL sets.
+ /// The culture to restrict variant content urls to, or null for all cultures.
+ /// A task representing the asynchronous operation, with a result containing an enumerable of representing the URL sets.
+ // TODO (V19): Remove the default implementation.
+ Task> CreateUrlSetsAsync(IEnumerable contentItems, string? culture)
+ => CreateUrlSetsAsync(contentItems);
+
///
/// Asynchronously retrieves preview URL information for the specified content item.
///
diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json
index 933db9690eee..53b6867f4692 100644
--- a/src/Umbraco.Cms.Api.Management/OpenApi.json
+++ b/src/Umbraco.Cms.Api.Management/OpenApi.json
@@ -11978,6 +11978,13 @@
"format": "uuid"
}
}
+ },
+ {
+ "name": "culture",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
}
],
"responses": {
diff --git a/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs
index 7a62697aa79b..44cfc81ac2b7 100644
--- a/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs
+++ b/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs
@@ -13,4 +13,17 @@ public interface IPublishedUrlInfoProvider
/// The content to get urls for.
/// Set of all published url infos.
Task> GetAllAsync(IContent content);
+
+ ///
+ /// Gets the published urls for a content item, optionally restricted to a single culture.
+ ///
+ /// The content to get urls for.
+ ///
+ /// The culture to restrict variant content urls to, or null to return urls for all cultures.
+ /// Ignored for invariant content, which always returns all of its domain urls.
+ ///
+ /// Set of published url infos.
+ // TODO (V19): Remove the default implementation.
+ Task> GetAllAsync(IContent content, string? culture)
+ => GetAllAsync(content);
}
diff --git a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs
index 6795538d5446..bff3ae9b01a4 100644
--- a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs
+++ b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs
@@ -56,41 +56,49 @@ public PublishedUrlInfoProvider(
}
///
- public async Task> GetAllAsync(IContent content)
+ public Task> GetAllAsync(IContent content)
+ => GetAllAsync(content, culture: null);
+
+ ///
+ public async Task> GetAllAsync(IContent content, string? culture)
{
- HashSet urlInfos = [];
var isInvariant = !content.ContentType.VariesByCulture();
- IEnumerable cultures = await GetCulturesForUrlLookupAsync(content);
-
- foreach (var culture in cultures)
+ // Variant content restricted to a single culture, matched against the installed cultures (using their casing).
+ if (isInvariant is false && culture is not null)
{
- var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture);
+ var matchedCulture = (await _languageService.GetAllIsoCodesAsync())
+ .FirstOrDefault(x => x.InvariantEquals(culture));
- // Handle "could not get URL"
- if (url is Constants.Routing.Unroutable or Constants.Routing.UrlProviderException)
- {
- // For invariant content, a missing URL just means there's no domain
- // for this culture — not a problem worth reporting.
- if (isInvariant)
- {
- continue;
- }
-
- urlInfos.Add(UrlInfo.AsMessage(_localizedTextService.Localize("content", "getUrlException"), UrlProviderAlias, culture));
- continue;
- }
+ // A specific culture was requested that is not an installed culture - there are no urls to report.
+ return matchedCulture is null
+ ? new HashSet()
+ : await BuildUrlInfosAsync(content, [matchedCulture], scopedCulture: matchedCulture, isInvariant);
+ }
- // Check for collision
- Attempt hasCollision = await VerifyCollisionAsync(content, url, culture);
+ // Invariant content (culture ignored), or all cultures.
+ IReadOnlyCollection cultures = (await GetCulturesForUrlLookupAsync(content)).ToArray();
+ return await BuildUrlInfosAsync(content, cultures, scopedCulture: null, isInvariant);
+ }
- if (hasCollision is { Success: true, Result: not null })
+ ///
+ /// Builds the set of for the given cultures, plus the "other" URLs (unless the content is
+ /// trashed). When is set, the "other" URLs are filtered to that culture.
+ ///
+ private async Task> BuildUrlInfosAsync(
+ IContent content,
+ IReadOnlyCollection cultures,
+ string? scopedCulture,
+ bool isInvariant)
+ {
+ var urlInfos = new HashSet();
+ foreach (var contentCulture in cultures)
+ {
+ UrlInfo? urlInfo = await GetCultureUrlInfoAsync(content, contentCulture, isInvariant);
+ if (urlInfo is not null)
{
- urlInfos.Add(hasCollision.Result);
- continue;
+ urlInfos.Add(urlInfo);
}
-
- urlInfos.Add(UrlInfo.AsUrl(url, UrlProviderAlias, culture));
}
// If the content is trashed, we can't get the other URLs, as we have no parent structure to navigate through.
@@ -99,9 +107,7 @@ public async Task> GetAllAsync(IContent content)
return urlInfos;
}
- // Then get "other" urls - I.E. Not what you'd get with GetUrl(), this includes all the urls registered using domains.
- // for these 'other' URLs, we don't check whether they are routable, collide, anything - we just report them.
- foreach (UrlInfo otherUrl in _publishedUrlProvider.GetOtherUrls(content.Id).OrderBy(x => x.Message).ThenBy(x => x.Culture))
+ foreach (UrlInfo otherUrl in GetOtherUrls(content, scopedCulture))
{
urlInfos.Add(otherUrl);
}
@@ -109,6 +115,39 @@ public async Task> GetAllAsync(IContent content)
return urlInfos;
}
+ ///
+ /// Gets the for a single culture, or null if there is nothing to report
+ /// (an unroutable URL on invariant content). Reports a message for an unroutable URL or a collision.
+ ///
+ private async Task GetCultureUrlInfoAsync(IContent content, string culture, bool isInvariant)
+ {
+ var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture);
+
+ if (url is Constants.Routing.Unroutable or Constants.Routing.UrlProviderException)
+ {
+ // For invariant content, a missing URL just means there's no domain for this culture - not worth reporting.
+ return isInvariant
+ ? null
+ : UrlInfo.AsMessage(_localizedTextService.Localize("content", "getUrlException"), UrlProviderAlias, culture);
+ }
+
+ Attempt hasCollision = await VerifyCollisionAsync(content, url, culture);
+ return hasCollision is { Success: true, Result: not null }
+ ? hasCollision.Result
+ : UrlInfo.AsUrl(url, UrlProviderAlias, culture);
+ }
+
+ ///
+ /// Gets the "other" URLs - i.e. not what you'd get with GetUrl(), including all the URLs registered using domains.
+ /// These are not checked for routability or collisions - they are just reported. When scoped to a single culture,
+ /// only the other URLs for that culture are returned.
+ ///
+ private IEnumerable GetOtherUrls(IContent content, string? scopedCulture)
+ => _publishedUrlProvider.GetOtherUrls(content.Id)
+ .Where(x => scopedCulture is null || string.Equals(x.Culture, scopedCulture, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(x => x.Message)
+ .ThenBy(x => x.Culture);
+
///
/// Gets the cultures to query URLs for.
/// For invariant content, returns only cultures that have a domain assigned to the content
diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts
index ea3e91775aec..26b3378ca840 100644
--- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts
+++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts
@@ -10,13 +10,20 @@ export const urlHandlers = [
const ids = url.searchParams.getAll('id');
if (!ids.length) return new HttpResponse(null, { status: 400 });
+ // When a culture is provided, variant URLs are restricted to that culture (invariant URLs, which have
+ // no culture, are always kept). This mirrors the Management API's document URLs endpoint.
+ const culture = url.searchParams.get('culture');
+
const response: GetDocumentUrlsResponse = ids.map((id) => ({
id,
- urlInfos: umbDocumentMockDb.url.getUrls(id).map((urlInfo) => ({
- ...urlInfo,
- message: null,
- provider: 'Default',
- })),
+ urlInfos: umbDocumentMockDb.url
+ .getUrls(id)
+ .filter((urlInfo) => !culture || urlInfo.culture === null || urlInfo.culture === culture)
+ .map((urlInfo) => ({
+ ...urlInfo,
+ message: null,
+ provider: 'Default',
+ })),
}));
return HttpResponse.json(response);
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts
index bfd94ec7838d..0ede83735d5b 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts
@@ -7635,6 +7635,7 @@ export type GetDocumentUrlsData = {
path?: never;
query?: {
id?: Array;
+ culture?: string;
};
url: '/umbraco/management/api/v1/document/urls';
};
diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts
index 9f6bec2980dc..200a2bac9064 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts
@@ -1,7 +1,7 @@
import type { UmbDocumentUrlModel } from './repository/types.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
-import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
+import { UmbArrayState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UMB_VARIANT_CONTEXT, type UmbVariantId } from '@umbraco-cms/backoffice/variant';
/**
@@ -11,14 +11,13 @@ import { UMB_VARIANT_CONTEXT, type UmbVariantId } from '@umbraco-cms/backoffice/
* @augments {UmbControllerBase}
*/
export class UmbDocumentUrlsDataResolver extends UmbControllerBase {
- #appCulture?: string;
#variantId?: UmbVariantId;
#displayVariantId?: UmbVariantId;
#data?: Array | undefined;
- #init: Promise;
+ readonly #init: Promise;
- #urls = new UmbArrayState([], (url) => url.url);
+ readonly #urls = new UmbArrayState([], (url) => url.url);
/**
* The urls for the current culture
* @returns {ObservableArray} The urls for the current culture
@@ -26,13 +25,35 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase {
*/
public readonly urls = this.#urls.asObservable();
+ readonly #requestCulture = new UmbStringState(undefined);
+ /**
+ * The culture to request urls for from the server. Emits whenever the displayed culture changes.
+ * Resolves to undefined for invariant documents (meaning all cultures).
+ * @returns {Observable} The culture to request urls for
+ * @memberof UmbDocumentUrlsDataResolver
+ */
+ public readonly requestCulture = this.#requestCulture.asObservable();
+
constructor(host: UmbControllerHost) {
super(host);
this.#init = Promise.all([
this.consumeContext(UMB_VARIANT_CONTEXT, async (context) => {
this.#variantId = await context?.getVariantId();
- this.#displayVariantId = await this.observe(context?.displayVariantId)?.asPromise();
+
+ // Observe the display variant id so the resolver reacts to culture switches, rather than
+ // capturing the culture only once.
+ if (context) {
+ this.observe(
+ context.displayVariantId,
+ (displayVariantId) => {
+ this.#displayVariantId = displayVariantId;
+ this.#setCultureAwareValues();
+ },
+ 'observeDisplayVariantId',
+ );
+ }
+
this.#setCultureAwareValues();
}).asPromise(),
]);
@@ -73,8 +94,24 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase {
return this.#urls.getValue();
}
+ /**
+ * Gets the culture to request urls for from the server.
+ * Returns the current culture for variant documents, or undefined for invariant documents
+ * (which must return all of their domain urls).
+ * @returns {Promise} The culture to request, or undefined for all cultures
+ * @memberof UmbDocumentUrlsDataResolver
+ */
+ async getRequestCulture(): Promise {
+ await this.#init;
+ return this.#requestCulture.getValue();
+ }
+
#setCultureAwareValues() {
this.#setUrls();
+
+ // TODO: when the variant context gains a dedicated "request/effective culture" accessor, resolve the
+ // invariant->all-cultures decision through it instead of here.
+ this.#requestCulture.setValue(this.#variantId?.isCultureInvariant() ? undefined : this.#getCurrentCulture());
}
#setUrls() {
@@ -83,7 +120,9 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase {
}
#getCurrentCulture(): string | undefined {
- return this.#variantId?.culture || this.#displayVariantId?.culture || this.#appCulture;
+ // Culture resolution (incl. inheritance/fallback) is owned by the variant context; we read its
+ // already-resolved display variant rather than re-deriving it here.
+ return this.#displayVariantId?.culture ?? undefined;
}
#getDataForCurrentCulture(): Array | undefined {
diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts
index 5b3c61c1b69c..7dfc480cbad3 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts
@@ -84,7 +84,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement {
if (unique !== this._unique) {
this._unique = unique;
- this.#requestUrls();
+ this.#scheduleRequestUrls();
}
},
'observeWorkspaceState',
@@ -99,6 +99,13 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement {
});
});
+ // Re-request when the displayed culture changes, so switching language fetches that culture.
+ this.observe(
+ this.#documentUrlsDataResolver?.requestCulture,
+ () => this.#scheduleRequestUrls(),
+ 'observeRequestCulture',
+ );
+
this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => {
this.#propertyDataSetVariantId = context?.getVariantId();
this.#setLinks();
@@ -136,20 +143,33 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement {
return url;
}
+ // Show the loading indicator immediately (synchronously), before the debounced request runs, so no
+ // stale "no URL" message is shown while the URL is (re)resolved - e.g. on load or when switching culture.
+ #scheduleRequestUrls() {
+ if (this._isNew || !this._unique) return;
+
+ this._loading = true;
+ this.#debounceRequestUrls();
+ }
+
async #requestUrls() {
- if (this._isNew) return;
- if (!this._unique) return;
+ if (this._isNew || !this._unique) return;
this._loading = true;
this.#documentUrlsDataResolver?.setData([]);
- const { data } = await this.#documentUrlRepository.requestItems([this._unique]);
+ try {
+ // Only request the culture currently being displayed for variant documents. Invariant documents
+ // return all of their domain urls, so no culture is passed (getRequestCulture resolves undefined).
+ const culture = await this.#documentUrlsDataResolver?.getRequestCulture();
+ const { data } = await this.#documentUrlRepository.requestUrls([this._unique], culture);
- if (data?.length) {
- this.#documentUrlsDataResolver?.setData(data[0].urls);
+ if (data?.length) {
+ this.#documentUrlsDataResolver?.setData(data[0].urls);
+ }
+ } finally {
+ this._loading = false;
}
-
- this._loading = false;
}
#getStateLocalizationKey(state: UmbDocumentVariantState | null | undefined): string {
@@ -173,7 +193,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement {
// TODO: Introduce "Published Event". We only need to update the url when the document is published.
if (event.getUnique() !== this.#documentWorkspaceContext?.getUnique()) return;
if (event.getEntityType() !== this.#documentWorkspaceContext.getEntityType()) return;
- this.#debounceRequestUrls();
+ this.#scheduleRequestUrls();
};
override render() {
diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts
index 6b65fe354598..54e20d0b9500 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts
@@ -5,9 +5,36 @@ import { UmbItemRepositoryBase } from '@umbraco-cms/backoffice/repository';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export class UmbDocumentUrlRepository extends UmbItemRepositoryBase {
+ // The culture-aware request needs its own data source instance: `UmbItemRepositoryBase` keeps its
+ // data source private and the inherited `requestItems` signature cannot carry a culture. Extending
+ // the item base keeps this consistent with `UmbMediaUrlRepository`; `requestUrls` otherwise mirrors
+ // the base flow, including store population.
+ readonly #urlSource = new UmbDocumentUrlServerDataSource(this);
+
constructor(host: UmbControllerHost) {
super(host, UmbDocumentUrlServerDataSource, UMB_DOCUMENT_URL_STORE_CONTEXT);
}
+
+ /**
+ * Requests the urls for the given uniques, optionally restricted to a single culture.
+ * @param {Array} uniques The document uniques to request urls for.
+ * @param {string} [culture] The culture to restrict variant document urls to, or undefined for all cultures.
+ * @returns {*} The requested url data.
+ * @memberof UmbDocumentUrlRepository
+ */
+ async requestUrls(uniques: Array, culture?: string) {
+ if (!uniques) throw new Error('Uniques are missing');
+
+ await this._init;
+
+ const { data, error } = await this.#urlSource.getItems(uniques, culture);
+
+ if (data) {
+ this._itemStore?.appendItems(data);
+ }
+
+ return { data, error, asObservable: () => this._itemStore?.items(uniques) };
+ }
}
export { UmbDocumentUrlRepository as api };
diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts
new file mode 100644
index 000000000000..dc092a3d6a83
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts
@@ -0,0 +1,46 @@
+import { UmbDocumentUrlServerDataSource } from './document-url.server.data-source.js';
+import { expect } from '@open-wc/testing';
+import { customElement } from '@umbraco-cms/backoffice/external/lit';
+import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
+import { useMockSet } from '@umbraco-cms/internal/mock-manager';
+
+@customElement('test-document-url-data-source-host')
+class UmbTestDocumentUrlDataSourceHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
+
+// A variant document from the 'documents' mock set with URLs in two cultures ('en-US' and 'da').
+const VARIANT_DOCUMENT_ID = 'variant-documents-variant-document-id';
+
+describe('UmbDocumentUrlServerDataSource', () => {
+ let hostElement: UmbTestDocumentUrlDataSourceHostElement;
+ let dataSource: UmbDocumentUrlServerDataSource;
+
+ before(async () => {
+ await useMockSet('documents');
+ });
+
+ beforeEach(() => {
+ hostElement = new UmbTestDocumentUrlDataSourceHostElement();
+ document.body.appendChild(hostElement);
+ dataSource = new UmbDocumentUrlServerDataSource(hostElement);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('requests only the given culture when one is provided', async () => {
+ const { data, error } = await dataSource.getItems([VARIANT_DOCUMENT_ID], 'da');
+
+ expect(error).to.be.undefined;
+ expect(data?.[0].unique).to.equal(VARIANT_DOCUMENT_ID);
+ expect(data?.[0].urls.map((url) => url.culture)).to.eql(['da']);
+ });
+
+ it('omits the culture when none is provided, so all cultures are returned', async () => {
+ const { data, error } = await dataSource.getItems([VARIANT_DOCUMENT_ID]);
+
+ expect(error).to.be.undefined;
+ expect(data?.[0].unique).to.equal(VARIANT_DOCUMENT_ID);
+ expect(data?.[0].urls.map((url) => url.culture)).to.have.members(['en-US', 'da']);
+ });
+});
diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts
index 89df34cac81c..62eb65960d92 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts
@@ -23,12 +23,12 @@ export class UmbDocumentUrlServerDataSource extends UmbItemServerDataSourceBase<
super(host, { mapper });
}
- override async getItems(uniques: Array) {
+ override async getItems(uniques: Array, culture?: string) {
if (!uniques) throw new Error('Uniques are missing');
const itemRequestManager = new UmbItemDataApiGetRequestController(this, {
// eslint-disable-next-line local-rules/no-direct-api-import
- api: (args) => DocumentService.getDocumentUrls({ query: { id: args.uniques } }),
+ api: (args) => DocumentService.getDocumentUrls({ query: { id: args.uniques, culture } }),
uniques,
});
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs
index 931d1cde66aa..cbb26e162b74 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs
@@ -44,6 +44,29 @@ public async Task Invariant_Content_Without_Domain_Returns_Only_Default_Language
Assert.AreEqual("en-US", urls.First().Culture, "The URL should be for the default language (en-US)");
}
+ [Test]
+ public async Task Can_Ignore_Requested_Culture_For_Invariant_Content()
+ {
+ // Arrange: Add a second language (Danish) alongside the default English
+ var danishLanguage = new LanguageBuilder()
+ .WithCultureInfo("da-DK")
+ .WithCultureName("Danish")
+ .Build();
+ await LanguageService.CreateAsync(danishLanguage, Constants.Security.SuperUserKey);
+
+ // The base class creates invariant content
+ ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]);
+
+ // Act: request with and without a culture
+ var allUrls = await PublishedUrlInfoProvider.GetAllAsync(Textpage);
+ var cultureScopedUrls = await PublishedUrlInfoProvider.GetAllAsync(Textpage, "da-DK");
+
+ // Assert: invariant content ignores the requested culture and returns the same urls
+ CollectionAssert.AreEquivalent(
+ allUrls.Select(x => x.Url?.ToString()),
+ cultureScopedUrls.Select(x => x.Url?.ToString()));
+ }
+
[Test]
public async Task Invariant_Content_Under_Non_Default_Language_Domain_Returns_Only_Domain_Url()
{
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs
index 552b95218927..397e1aecd2bb 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs
@@ -7,7 +7,6 @@
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Packaging;
-using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
@@ -239,6 +238,66 @@ public async Task Can_Resolve_Urls_With_Domains_For_All_Cultures()
});
}
+ [Test]
+ public async Task Can_Resolve_Urls_Restricted_To_A_Single_Culture()
+ {
+ var domainService = GetRequiredService();
+ var updateModel = new DomainsUpdateModel
+ {
+ Domains = Cultures.Select(culture => new DomainModel
+ {
+ DomainName = GetDomainUrlFromCultureCode(culture),
+ IsoCode = culture,
+ })
+ };
+
+ var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
+ Assert.IsTrue(result.Success);
+
+ var culture = Cultures[1];
+ var publishedUrlInfoProvider = GetRequiredService();
+
+ var allUrls = await publishedUrlInfoProvider.GetAllAsync(Root);
+ var cultureUrls = await publishedUrlInfoProvider.GetAllAsync(Root, culture);
+
+ Assert.Multiple(() =>
+ {
+ // The culture-scoped result contains only the requested culture.
+ Assert.IsNotEmpty(cultureUrls);
+ CollectionAssert.AreEquivalent(new[] { culture }, cultureUrls.Select(x => x.Culture).Distinct());
+
+ // The culture-scoped result equals the all-cultures result filtered to that culture.
+ CollectionAssert.AreEquivalent(
+ allUrls.Where(x => x.Culture == culture).Select(x => x.Url?.ToString()),
+ cultureUrls.Select(x => x.Url?.ToString()));
+ });
+ }
+
+ [Test]
+ public async Task Cannot_Resolve_Urls_For_An_Unknown_Culture()
+ {
+ var domainService = GetRequiredService();
+ var updateModel = new DomainsUpdateModel
+ {
+ Domains = Cultures.Select(culture => new DomainModel
+ {
+ DomainName = GetDomainUrlFromCultureCode(culture),
+ IsoCode = culture,
+ })
+ };
+
+ var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
+ Assert.IsTrue(result.Success);
+
+ var publishedUrlInfoProvider = GetRequiredService();
+
+ var unknownCultureUrls = await publishedUrlInfoProvider.GetAllAsync(Root, "xx-XX");
+
+ // An unknown culture is not an installed culture, so there are no urls to report for it -
+ // rather than falling back to (expensively) resolving all cultures.
+ Assert.IsEmpty(unknownCultureUrls);
+ }
+
[Test]
public async Task Can_Resolve_Urls_For_Non_Default_Domain_Culture_Only()
{
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs
new file mode 100644
index 000000000000..1590a2c050a5
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs
@@ -0,0 +1,94 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Api.Management.Factories;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Routing;
+using Umbraco.Cms.Core.Security;
+using Umbraco.Cms.Core.Services;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Factories;
+
+[TestFixture]
+public class DocumentUrlFactoryTests
+{
+ private static (DocumentUrlFactory Factory, Mock Provider) CreateFactory()
+ {
+ var provider = new Mock();
+
+ // Return a single url tagged with the requested culture so assertions can distinguish calls.
+ provider
+ .Setup(x => x.GetAllAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((IContent _, string? culture) =>
+ new HashSet { UrlInfo.AsUrl($"/{culture ?? "all"}/", "test", culture) });
+
+ var factory = new DocumentUrlFactory(
+ provider.Object,
+ new UrlProviderCollection(() => []),
+ Mock.Of(),
+ Mock.Of(),
+ NullLogger.Instance);
+
+ return (factory, provider);
+ }
+
+ private static IContent CreateContent(Guid? key = null)
+ {
+ var content = new Mock();
+ content.SetupGet(x => x.Key).Returns(key ?? Guid.NewGuid());
+ return content.Object;
+ }
+
+ [Test]
+ public async Task Can_Pass_Requested_Culture_To_Provider()
+ {
+ var (factory, provider) = CreateFactory();
+ var content = CreateContent();
+
+ var result = (await factory.CreateUrlsAsync(content, "da-DK")).ToList();
+
+ provider.Verify(x => x.GetAllAsync(content, "da-DK"), Times.Once);
+ Assert.AreEqual(1, result.Count);
+ Assert.AreEqual("da-DK", result[0].Culture);
+ }
+
+ [Test]
+ public async Task Can_Request_All_Cultures_When_No_Culture_Provided()
+ {
+ var (factory, provider) = CreateFactory();
+ var content = CreateContent();
+
+ await factory.CreateUrlsAsync(content);
+
+ // The parameterless overload must delegate with a null culture (all cultures).
+ provider.Verify(x => x.GetAllAsync(content, null), Times.Once);
+ }
+
+ [Test]
+ public async Task Can_Pass_Requested_Culture_For_Every_Item()
+ {
+ var (factory, provider) = CreateFactory();
+ var contentA = CreateContent();
+ var contentB = CreateContent();
+
+ var result = (await factory.CreateUrlSetsAsync([contentA, contentB], "da-DK")).ToList();
+
+ provider.Verify(x => x.GetAllAsync(contentA, "da-DK"), Times.Once);
+ provider.Verify(x => x.GetAllAsync(contentB, "da-DK"), Times.Once);
+
+ Assert.AreEqual(2, result.Count);
+ Assert.AreEqual(contentA.Key, result[0].Id);
+ Assert.AreEqual(contentB.Key, result[1].Id);
+ }
+
+ [Test]
+ public async Task Can_Request_All_Cultures_For_Url_Sets_When_No_Culture_Provided()
+ {
+ var (factory, provider) = CreateFactory();
+ var content = CreateContent();
+
+ await factory.CreateUrlSetsAsync([content]);
+
+ provider.Verify(x => x.GetAllAsync(content, null), Times.Once);
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs
new file mode 100644
index 000000000000..5c2e65a79090
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs
@@ -0,0 +1,229 @@
+using System.Globalization;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Hosting;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Models.PublishedContent;
+using Umbraco.Cms.Core.Routing;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Web;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Routing;
+
+[TestFixture]
+public class PublishedUrlInfoProviderTests
+{
+ private static readonly Guid _contentKey = new("2b3f9a4d-0c1e-4b6a-9c2d-1e2f3a4b5c6d");
+ private const int ContentId = 1234;
+
+ private static readonly string[] _installedCultures = ["en-US", "da-DK"];
+
+ private Mock _urlProvider = null!;
+ private Mock _languageService = null!;
+ private Mock _router = null!;
+ private Mock _textService = null!;
+ private IPublishedRequest _routeResult = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _urlProvider = new Mock();
+ _urlProvider
+ .Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((Guid _, UrlMode _, string? culture, Uri? _) => $"https://example.com/{culture}/");
+ _urlProvider.Setup(x => x.GetOtherUrls(It.IsAny())).Returns(Array.Empty());
+
+ _languageService = new Mock();
+ _languageService.Setup(x => x.GetAllAsync()).ReturnsAsync(_installedCultures.Select(CreateLanguage).ToArray());
+ _languageService.Setup(x => x.GetDefaultIsoCodeAsync()).ReturnsAsync("en-US");
+
+ // Default routing result: the generated URL routes back to the same content (no collision).
+ _routeResult = CreateRequest(ContentId);
+ _router = new Mock();
+ _router.Setup(x => x.CreateRequestAsync(It.IsAny())).ReturnsAsync(Mock.Of());
+ _router
+ .Setup(x => x.RouteRequestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => _routeResult);
+
+ _textService = new Mock();
+ _textService
+ .Setup(x => x.Localize(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>()))
+ .Returns((string? _, string? alias, CultureInfo? _, IDictionary? _) => alias ?? string.Empty);
+ }
+
+ [Test]
+ public async Task Can_Get_Url_For_Each_Installed_Culture_When_No_Culture_Requested()
+ {
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true));
+
+ Assert.AreEqual(2, result.Count);
+ CollectionAssert.AreEquivalent(_installedCultures, result.Select(x => x.Culture));
+ Assert.IsTrue(result.All(x => x.Url is not null));
+ }
+
+ [Test]
+ public async Task Can_Restrict_Urls_To_A_Single_Requested_Culture()
+ {
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK");
+
+ Assert.AreEqual(1, result.Count);
+ Assert.AreEqual("da-DK", result.Single().Culture);
+ }
+
+ [Test]
+ public async Task Can_Match_Requested_Culture_Case_Insensitively()
+ {
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "DA-dk");
+
+ Assert.AreEqual(1, result.Count);
+ Assert.AreEqual("da-DK", result.Single().Culture, "The installed culture's casing should be used.");
+ }
+
+ [Test]
+ public async Task Cannot_Get_Urls_For_An_Unknown_Culture()
+ {
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "xx-XX");
+
+ Assert.IsEmpty(result);
+ _urlProvider.Verify(
+ x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Test]
+ public async Task Cannot_Get_Url_For_Unroutable_Content_And_Reports_Message()
+ {
+ _urlProvider
+ .Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), "da-DK", It.IsAny()))
+ .Returns(Constants.Routing.Unroutable);
+
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK");
+
+ UrlInfo info = result.Single();
+ Assert.IsNull(info.Url);
+ Assert.AreEqual("getUrlException", info.Message);
+ }
+
+ [Test]
+ public async Task Cannot_Get_Url_When_Route_Collides_And_Reports_Message()
+ {
+ // The generated URL routes back to a different content item.
+ _routeResult = CreateRequest(resolvedContentId: 9999);
+
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK");
+
+ UrlInfo info = result.Single();
+ Assert.IsNull(info.Url);
+ Assert.AreEqual("routeError", info.Message);
+ }
+
+ [Test]
+ public async Task Cannot_Get_Url_When_Route_Is_Unresolvable_And_Reports_Message()
+ {
+ // The generated URL does not route back to any content item.
+ _routeResult = CreateRequest(resolvedContentId: null);
+
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK");
+
+ UrlInfo info = result.Single();
+ Assert.IsNull(info.Url);
+ Assert.AreEqual("routeErrorCannotRoute", info.Message);
+ }
+
+ [Test]
+ public async Task Can_Get_Url_When_Collisions_Are_Ignored()
+ {
+ // Routes back to different content, but the request opts out of collision checks.
+ _routeResult = CreateRequest(resolvedContentId: 9999, ignoreCollisions: true);
+
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK");
+
+ UrlInfo info = result.Single();
+ Assert.IsNotNull(info.Url);
+ Assert.AreEqual("da-DK", info.Culture);
+ }
+
+ [Test]
+ public async Task Cannot_Get_Other_Urls_For_Trashed_Content()
+ {
+ _urlProvider
+ .Setup(x => x.GetOtherUrls(ContentId))
+ .Returns(new[] { UrlInfo.AsUrl("https://example.com/other/", "content", "da-DK") });
+
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true, trashed: true), "da-DK");
+
+ _urlProvider.Verify(x => x.GetOtherUrls(It.IsAny()), Times.Never);
+ Assert.IsFalse(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other/")));
+ }
+
+ [Test]
+ public async Task Can_Get_Other_Urls_Filtered_To_The_Scoped_Culture()
+ {
+ _urlProvider.Setup(x => x.GetOtherUrls(ContentId)).Returns(new[]
+ {
+ UrlInfo.AsUrl("https://example.com/other-da/", "content", "da-DK"),
+ UrlInfo.AsUrl("https://example.com/other-en/", "content", "en-US"),
+ });
+
+ ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK");
+
+ Assert.Multiple(() =>
+ {
+ Assert.IsTrue(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other-da/")));
+ Assert.IsFalse(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other-en/")));
+ });
+ }
+
+ private PublishedUrlInfoProvider CreateSut()
+ {
+ var hostingEnvironment = new Mock();
+ hostingEnvironment.Setup(x => x.ApplicationVirtualPath).Returns("/");
+
+ return new PublishedUrlInfoProvider(
+ _urlProvider.Object,
+ _languageService.Object,
+ _router.Object,
+ Mock.Of(),
+ _textService.Object,
+ NullLogger.Instance,
+ new UriUtility(hostingEnvironment.Object),
+ Mock.Of());
+ }
+
+ private static ILanguage CreateLanguage(string isoCode)
+ {
+ var language = new Mock();
+ language.SetupGet(x => x.IsoCode).Returns(isoCode);
+ return language.Object;
+ }
+
+ private static IContent CreateContent(bool variesByCulture, bool trashed = false)
+ {
+ var contentType = new Mock();
+ contentType.SetupGet(x => x.Variations).Returns(variesByCulture ? ContentVariation.Culture : ContentVariation.Nothing);
+
+ var content = new Mock();
+ content.SetupGet(x => x.ContentType).Returns(contentType.Object);
+ content.SetupGet(x => x.Key).Returns(_contentKey);
+ content.SetupGet(x => x.Id).Returns(ContentId);
+ content.SetupGet(x => x.Trashed).Returns(trashed);
+ return content.Object;
+ }
+
+ private static IPublishedRequest CreateRequest(int? resolvedContentId, bool ignoreCollisions = false)
+ {
+ var request = new Mock();
+ if (resolvedContentId is not null)
+ {
+ var publishedContent = new Mock();
+ publishedContent.SetupGet(x => x.Id).Returns(resolvedContentId.Value);
+ publishedContent.SetupGet(x => x.Key).Returns(Guid.NewGuid());
+ request.SetupGet(x => x.PublishedContent).Returns(publishedContent.Object);
+ }
+
+ request.SetupGet(x => x.IgnorePublishedContentCollisions).Returns(ignoreCollisions);
+ return request.Object;
+ }
+}