diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1fac3fa8..d25546e9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,9 @@ Our versioning strategy is as follows: ### 🎉 New Features & Improvements * `[nextjs]` `[create-content-sdk-app]` Enable Next.js 16 Cache Components and Turbopack File System Caching ([#334](https://github.com/Sitecore/content-sdk/pull/334)) - - Enabled `cacheComponents: true` for explicit caching with "use cache" directive - - Enabled `experimental.turbopackFileSystemCacheForDev: true` for faster dev startup (beta) - - Available in both Pages Router and App Router templates + +* `[core]` `[content]` `[nextjs]` Support custom Edge hostnames via `SITECORE_EDGE_PLATFORM_HOSTNAME` (Next.js: `NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME`) ([#359](https://github.com/Sitecore/content-sdk/pull/359)) + - New `rewriteMediaUrls` option: when `true`, rewrites layout media URLs to the custom Edge hostname; when a function, applies a custom string transformer. * Search integration ([#295](https://github.com/Sitecore/content-sdk/pull/295)) * `[search]` New `@sitecore-content-sdk/search` package providing search functionality diff --git a/api-extractor.json b/api-extractor.json index 3bc9e928ce..be99d360d9 100644 --- a/api-extractor.json +++ b/api-extractor.json @@ -67,6 +67,9 @@ "tsdoc-html-tag-missing-greater-than": { "logLevel": "none" }, + "tsdoc-html-tag-missing-equals": { + "logLevel": "none" + }, "tsdoc-at-sign-in-word": { "logLevel": "none" } diff --git a/packages/content/api/content-sdk-content.api.md b/packages/content/api/content-sdk-content.api.md index cb9627152e..f424858fe2 100644 --- a/packages/content/api/content-sdk-content.api.md +++ b/packages/content/api/content-sdk-content.api.md @@ -30,6 +30,9 @@ export const addServerComponentPreviewHandler: (callback: (eventArgs: ComponentP // @internal export function addStyleElement(stylesContent: string): void; +// @internal +export function applyMediaUrlRewrite(value: T, transform: (s: string) => string): T; + // @public export class CdpHelper { static getComponentFriendlyId(pageId: string, componentId: string, language: string, scope?: string): string; @@ -183,6 +186,9 @@ export interface ComponentUpdateEventArgs { name: string; } +// @public +export function containsDefaultEdgeHost(str: string): boolean; + // @internal export const createComponentInstance: (importMap: ImportEntry[], previewEventArgs: ComponentPreviewEventArgs) => unknown; @@ -416,11 +422,11 @@ export enum ErrorPage { export type ErrorPages = { notFoundPage: { rendered: LayoutServiceData; - }; + } | null; notFoundPagePath: string; serverErrorPage: { rendered: LayoutServiceData; - }; + } | null; serverErrorPagePath: string; }; @@ -532,6 +538,9 @@ export const getContentSdkPagesClientData: () => Record HTMLLink | null; +// @internal +export function getDefaultMediaUrlTransformer(edgeUrl: string): (value: string) => string; + // Warning: (ae-forgotten-export) The symbol "DesignLibraryComponentPreviewErrorEvent" needs to be exported by the entry point api-surface.d.ts // // @internal @@ -977,6 +986,9 @@ export const resetEditorChromes: () => void; export { RetryStrategy } +// @public +export function rewriteEdgeHostInResponse(response: T, edgeUrl: string): T; + // @public export type RobotsQueryResult = { site: { @@ -1087,6 +1099,8 @@ export type SitecoreCliConfigInput = { // @public export class SitecoreClient implements BaseSitecoreClient { constructor(initOptions: SitecoreClientInit); + // @internal + protected applyContentRewrite(layout: LayoutServiceData): LayoutServiceData; // (undocumented) protected clientFactory: GraphQLRequestClientFactory; // (undocumented) @@ -1190,6 +1204,7 @@ export type SitecoreConfigInput = { enabled?: boolean; locales?: string[]; }; + rewriteMediaUrls?: boolean | ((value: string) => string); disableCodeGeneration?: boolean; }; @@ -1338,7 +1353,7 @@ export type WriteImportMapArgsInternal = WriteImportMapArgs & { // Warnings were encountered during analysis: // -// src/client/sitecore-client.ts:58:3 - (ae-forgotten-export) The symbol "PageModeName" needs to be exported by the entry point api-surface.d.ts +// src/client/sitecore-client.ts:65:3 - (ae-forgotten-export) The symbol "PageModeName" needs to be exported by the entry point api-surface.d.ts // src/editing/codegen/preview.ts:108:5 - (ae-forgotten-export) The symbol "ComponentImport_2" needs to be exported by the entry point api-surface.d.ts // src/tools/generate-map.ts:24:3 - (ae-incompatible-release-tags) The symbol "mapTemplate" is marked as @public, but its signature references "ComponentMapTemplate" which is marked as @internal // src/tools/generate-map.ts:24:3 - (ae-incompatible-release-tags) The symbol "mapTemplate" is marked as @public, but its signature references "EnhancedComponentMapTemplate" which is marked as @internal diff --git a/packages/content/src/client/edge-proxy.test.ts b/packages/content/src/client/edge-proxy.test.ts index 29bc72659e..03d0e7090f 100644 --- a/packages/content/src/client/edge-proxy.test.ts +++ b/packages/content/src/client/edge-proxy.test.ts @@ -3,14 +3,14 @@ import { expect } from 'chai'; import { constants } from '@sitecore-content-sdk/core'; import { getEdgeProxyContentUrl, getEdgeProxyFormsUrl } from './edge-proxy'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; describe('edge-proxy', () => { describe('getEdgeProxyContentUrl', () => { it('should return url', () => { const url = getEdgeProxyContentUrl(); - expect(url).to.equal(`${SITECORE_EDGE_URL_DEFAULT}/v1/content/api/graphql/v1`); + expect(url).to.equal(`${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/content/api/graphql/v1`); }); it('should return url when custom sitecoreEdgeUrl is provided', () => { @@ -38,7 +38,7 @@ describe('edge-proxy', () => { const url = getEdgeProxyFormsUrl(sitecoreEdgeContextId, formId); expect(url).to.equal( - `${SITECORE_EDGE_URL_DEFAULT}/v1/forms/publisher/${formId}?sitecoreContextId=${sitecoreEdgeContextId}` + `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/forms/publisher/${formId}?sitecoreContextId=${sitecoreEdgeContextId}` ); }); diff --git a/packages/content/src/client/edge-proxy.ts b/packages/content/src/client/edge-proxy.ts index 69ac2f0b3d..35c596ba83 100644 --- a/packages/content/src/client/edge-proxy.ts +++ b/packages/content/src/client/edge-proxy.ts @@ -1,30 +1,36 @@ import { constants } from '@sitecore-content-sdk/core'; import { normalizeUrl } from '@sitecore-content-sdk/core/tools'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +/** + * Resolves the base Edge URL from config. Caller should pass the resolved Edge URL from config. + * @param {string} [sitecoreEdgeUrl] - The base Edge URL from config. Defaults to platform URL. + * @internal + */ +const getBaseEdgeUrl = ( + sitecoreEdgeUrl: string = constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT +): string => normalizeUrl(sitecoreEdgeUrl); /** * Generates a URL for accessing Sitecore Edge Platform Content using the provided endpoint and context ID. - * @param {string} [sitecoreEdgeUrl] - The base endpoint URL for the Edge Platform. Default is https://edge-platform.sitecorecloud.io + * @param {string} [sitecoreEdgeUrl] - The base endpoint URL for the Edge Platform (resolved at config level). Defaults to platform URL. * @returns {string} The complete URL for accessing content through the Edge Platform. * @public */ -export const getEdgeProxyContentUrl = (sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT) => - `${normalizeUrl(sitecoreEdgeUrl)}/v1/content/api/graphql/v1`; +export const getEdgeProxyContentUrl = ( + sitecoreEdgeUrl: string = constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT +) => `${getBaseEdgeUrl(sitecoreEdgeUrl)}/v1/content/api/graphql/v1`; /** * Generates a URL for accessing Sitecore Edge Platform Forms using the provided form ID and context ID. * @param {string} sitecoreEdgeContextId - The unique context id. * @param {string} formId - The unique form id. - * @param {string} [sitecoreEdgeUrl] - The base endpoint URL for the Edge Platform. Default is https://edge-platform.sitecorecloud.io + * @param {string} [sitecoreEdgeUrl] - The base endpoint URL for the Edge Platform (resolved at config level). Defaults to platform URL. * @returns {string} The complete URL for accessing forms through the Edge Platform. * @internal */ export const getEdgeProxyFormsUrl = ( sitecoreEdgeContextId: string, formId: string, - sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT + sitecoreEdgeUrl: string = constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT ) => - `${normalizeUrl( - sitecoreEdgeUrl - )}/v1/forms/publisher/${formId}?sitecoreContextId=${sitecoreEdgeContextId}`; + `${getBaseEdgeUrl(sitecoreEdgeUrl)}/v1/forms/publisher/${formId}?sitecoreContextId=${sitecoreEdgeContextId}`; diff --git a/packages/content/src/client/sitecore-client.test.ts b/packages/content/src/client/sitecore-client.test.ts index cebf157612..73678a9966 100644 --- a/packages/content/src/client/sitecore-client.test.ts +++ b/packages/content/src/client/sitecore-client.test.ts @@ -1,10 +1,13 @@ -/* eslint-disable dot-notation */ +/* eslint-disable dot-notation */ /* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ import chai, { expect } from 'chai'; import sinonChai from 'sinon-chai'; import sinon from 'sinon'; import { DocumentNode } from 'graphql'; -import { DefaultRetryStrategy, NativeDataFetcher } from '@sitecore-content-sdk/core'; +import { DefaultRetryStrategy, NativeDataFetcher, constants } from '@sitecore-content-sdk/core'; +import { SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV } from '@sitecore-content-sdk/core/tools'; + +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; import { ErrorPage, SitecoreClient } from './sitecore-client'; import { LayoutKind, DesignLibraryMode } from '../../src/editing'; import { LayoutServiceData } from '../../layout'; @@ -484,6 +487,62 @@ describe('SitecoreClient', () => { }); }); + it('should apply content rewrite when rewriteMediaUrls is a function', async () => { + const path = '/test/path'; + const locale = 'en-US'; + const siteInfo = { name: 'default-site', hostName: 'example.com', language: 'en' }; + const rawLayout = { + sitecore: { + route: { name: 'home', placeholders: {} }, + context: { site: siteInfo, pageState: LayoutServicePageState.Normal }, + }, + }; + layoutServiceStub.fetchLayoutData.returns(rawLayout); + const stringTransformer = (value: string) => + value === 'home' ? 'rewritten' : value; + const clientWithRewrite = new SitecoreClient({ + ...defaultInitOptions, + rewriteMediaUrls: stringTransformer, + } as any); + (clientWithRewrite as any).layoutService = layoutServiceStub; + + const result = await clientWithRewrite.getPage(path, { locale }); + + expect(result?.layout.sitecore.route?.name).to.equal('rewritten'); + }); + + it('should apply default Edge host rewrite when rewriteMediaUrls is true and custom hostname is set', async () => { + const path = '/test/path'; + const locale = 'en-US'; + const siteInfo = { name: 'default-site', hostName: 'example.com', language: 'en' }; + const rawLayout = { + sitecore: { + route: { + name: 'home', + placeholders: {}, + fields: { + image: { value: { src: 'https://edge.sitecorecloud.io/-/media/hero.jpg' } }, + }, + }, + context: { site: siteInfo, pageState: LayoutServicePageState.Normal }, + }, + }; + layoutServiceStub.fetchLayoutData.returns(rawLayout); + process.env[SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV] = 'custom.example.com'; + const clientWithRewrite = new SitecoreClient({ + ...defaultInitOptions, + rewriteMediaUrls: true, + } as any); + (clientWithRewrite as any).layoutService = layoutServiceStub; + + const result = await clientWithRewrite.getPage(path, { locale }); + + expect( + (result?.layout.sitecore.route?.fields?.image?.value as { src: string }).src + ).to.equal('https://custom.example.com/-/media/hero.jpg'); + delete process.env[SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV]; + }); + it('should pass fetchOptions to layoutService when calling getPage', async () => { const path = '/test/path'; const locale = 'en-US'; @@ -1350,11 +1409,11 @@ describe('SitecoreClient', () => { expect(result).to.deep.equal([ { - href: 'https://edge.example.com/v1/files/pages/styles/content-styles.css?sitecoreContextId=test-context-id', + href: `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/pages/styles/content-styles.css?sitecoreContextId=test-context-id`, rel: 'stylesheet', }, { - href: 'https://edge.example.com/v1/files/components/styles/foo.css?sitecoreContextId=test-context-id', + href: `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/components/styles/foo.css?sitecoreContextId=test-context-id`, rel: 'stylesheet', }, ]); @@ -1376,11 +1435,11 @@ describe('SitecoreClient', () => { expect(result).to.deep.equal([ { - href: 'https://edge.example.com/v1/files/pages/styles/content-styles.css?sitecoreContextId=client-context-id', + href: `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/pages/styles/content-styles.css?sitecoreContextId=client-context-id`, rel: 'stylesheet', }, { - href: 'https://edge.example.com/v1/files/components/styles/foo.css?sitecoreContextId=client-context-id', + href: `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/components/styles/foo.css?sitecoreContextId=client-context-id`, rel: 'stylesheet', }, ]); @@ -1393,7 +1452,7 @@ describe('SitecoreClient', () => { }); expect(result).to.deep.equal([ { - href: 'https://edge.example.com/v1/files/components/styles/foo.css?sitecoreContextId=test-context-id', + href: `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/components/styles/foo.css?sitecoreContextId=test-context-id`, rel: 'stylesheet', }, ]); @@ -1406,7 +1465,7 @@ describe('SitecoreClient', () => { }); expect(result).to.deep.equal([ { - href: 'https://edge.example.com/v1/files/pages/styles/content-styles.css?sitecoreContextId=test-context-id', + href: `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/pages/styles/content-styles.css?sitecoreContextId=test-context-id`, rel: 'stylesheet', }, ]); @@ -1454,6 +1513,29 @@ describe('SitecoreClient', () => { expect(result).to.equal(xmlContent); }); + it('should rewrite Edge hostnames in sitemap path and XML when custom hostname is configured', async () => { + process.env[SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV] = 'custom.example.com'; + const clientWithCustomEdge = new SitecoreClient({ + ...defaultInitOptions, + } as any); + + const edgeSitemapPath = 'https://edge.sitecorecloud.io/sitemap.xml'; + const xmlContent = + 'https://edge.sitecorecloud.io/a'; + + sitemapXmlServiceStub.getSitemap.resolves(edgeSitemapPath); + const dataFetcherStub = sandbox + .stub(NativeDataFetcher.prototype, 'fetch') + .resolves({ data: xmlContent, status: 200, statusText: 'OK' }); + + const result = await clientWithCustomEdge.getSiteMap({ ...defaultReqConfig }); + + expect(getGraphqlSitemapXMLServiceStub.calledWith(defaultReqConfig.siteName)).to.be.true; + expect(dataFetcherStub.calledWith('https://custom.example.com/sitemap.xml')).to.be.true; + expect(result).to.include('https://custom.example.com/a'); + delete process.env[SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV]; + }); + it('should fetch specific sitemap when ID is provided', async () => { const sitemapId = '1'; const absoluteSitemapPath = 'https://cdn.example.com/sitemap-1.xml'; diff --git a/packages/content/src/client/sitecore-client.ts b/packages/content/src/client/sitecore-client.ts index 1c80043fde..64473b9c03 100644 --- a/packages/content/src/client/sitecore-client.ts +++ b/packages/content/src/client/sitecore-client.ts @@ -7,6 +7,10 @@ import { NativeDataFetcher, debug, } from '@sitecore-content-sdk/core'; +import { + resolveEdgeUrlForStaticFiles, + resolveExperienceEdgeUrl, +} from '@sitecore-content-sdk/core/tools'; import { DictionaryPhrases, DictionaryService } from '../i18n'; import { getDesignLibraryStylesheetLinks, @@ -15,6 +19,9 @@ import { LayoutServiceData, RouteOptions, LayoutServicePageState, + rewriteEdgeHostInResponse, + getDefaultMediaUrlTransformer, + applyMediaUrlRewrite, } from '../layout'; import { HTMLLink, StaticPath } from '../models'; import { getGroomedVariantIds, PersonalizedRewriteData } from '../personalize/utils'; @@ -347,7 +354,7 @@ export class SitecoreClient implements BaseSitecoreClient { const locale = pageOptions?.locale ?? this.initOptions.defaultLanguage; const site = pageOptions?.site ?? this.initOptions.defaultSite; // Fetch layout data, passing on req/res for SSR - const layout = await this.layoutService.fetchLayoutData( + let layout = await this.layoutService.fetchLayoutData( computedPath, { locale, @@ -357,25 +364,24 @@ export class SitecoreClient implements BaseSitecoreClient { ); if (!layout.sitecore.route) { return null; - } else { - // Initialize links to be inserted on the page - if (pageOptions?.personalize?.variantId) { - // Modify layoutData to use specific variant(s) instead of default - // This will also set the variantId on the Sitecore context so that it is accessible here - personalizeLayout( - layout, - pageOptions.personalize.variantId, - pageOptions.personalize.componentVariantIds - ); - } - - return { + } + // Apply personalization first to select the variant(s) that will be used, + // then rewrite content URLs so we only process the selected variant(s) + if (pageOptions?.personalize?.variantId) { + personalizeLayout( layout, - siteName: layout.sitecore.context.site?.name || site, - locale, - mode: this.getPageMode(LayoutServicePageState.Normal), - }; + pageOptions.personalize.variantId, + pageOptions.personalize.componentVariantIds + ); } + layout = this.applyContentRewrite(layout); + + return { + layout, + siteName: layout.sitecore.context.site?.name || site, + locale, + mode: this.getPageMode(LayoutServicePageState.Normal), + }; } /** @@ -391,18 +397,20 @@ export class SitecoreClient implements BaseSitecoreClient { options: { enableStyles?: boolean; enableThemes?: boolean } = {} ): HTMLLink[] { const { enableStyles = true, enableThemes = true } = options; - const { contextId: serverContextId, clientContextId, edgeUrl } = this.initOptions.api.edge; + const { contextId: serverContextId, clientContextId } = this.initOptions.api.edge; const headLinks: HTMLLink[] = []; const contextId = serverContextId || clientContextId; + // Use default Edge URL for styles (ignore custom hostname) so stylesheets load from platform + const edgeUrlForStyles = resolveEdgeUrlForStaticFiles(); if (enableStyles) { - const contentStyles = getContentStylesheetLink(layoutData, contextId, edgeUrl); + const contentStyles = getContentStylesheetLink(layoutData, contextId, edgeUrlForStyles); if (contentStyles) headLinks.push(contentStyles); } if (enableThemes) { - headLinks.push(...getDesignLibraryStylesheetLinks(layoutData, contextId, edgeUrl)); + headLinks.push(...getDesignLibraryStylesheetLinks(layoutData, contextId, edgeUrlForStyles)); } return headLinks; @@ -473,14 +481,16 @@ export class SitecoreClient implements BaseSitecoreClient { if (!data) { throw new Error(`Unable to fetch editing data for preview ${JSON.stringify(previewData)}`); } + let layout = data.layoutData; + const personalizeData = getGroomedVariantIds(variantIds); + personalizeLayout(layout, personalizeData.variantId, personalizeData.componentVariantIds); + layout = this.applyContentRewrite(layout); const page: Page = { locale: language, - layout: data.layoutData, - siteName: data.layoutData.sitecore.context.site?.name || site, + layout, + siteName: layout.sitecore.context.site?.name || site, mode: this.getPageMode(mode), }; - const personalizeData = getGroomedVariantIds(variantIds); - personalizeLayout(page.layout, personalizeData.variantId, personalizeData.componentVariantIds); return page; } @@ -529,10 +539,11 @@ export class SitecoreClient implements BaseSitecoreClient { if (!componentData) { throw new Error(`Unable to fetch editing data for preview ${JSON.stringify(designLibData)}`); } + const layout = this.applyContentRewrite(componentData); const page: Page = { locale: designLibData.language, - layout: componentData, - siteName: componentData.sitecore.context.site?.name || site, + layout, + siteName: layout.sitecore.context.site?.name || site, mode: this.getPageMode(mode, generation), }; return page; @@ -561,7 +572,7 @@ export class SitecoreClient implements BaseSitecoreClient { fetchOptions ); - let layout = null; + let layout: LayoutServiceData | null = null; switch (code) { case ErrorPage.NotFound: @@ -578,6 +589,8 @@ export class SitecoreClient implements BaseSitecoreClient { return null; } + layout = this.applyContentRewrite(layout); + return { layout, locale, @@ -623,12 +636,14 @@ export class SitecoreClient implements BaseSitecoreClient { // regular sitemap if (sitemapPath) { try { + const experienceEdgeUrl = resolveExperienceEdgeUrl(); + const rewrittenSitemapPath = rewriteEdgeHostInResponse(sitemapPath, experienceEdgeUrl); const fetcher = new NativeDataFetcher(); - const xmlResponse = await fetcher.fetch(sitemapPath); + const xmlResponse = await fetcher.fetch(rewrittenSitemapPath); if (!xmlResponse.data) { throw new Error('REDIRECT_404'); } - return xmlResponse.data; + return rewriteEdgeHostInResponse(xmlResponse.data, experienceEdgeUrl); // eslint-disable-next-line no-unused-vars } catch (error) { throw new Error('REDIRECT_404'); @@ -701,6 +716,24 @@ export class SitecoreClient implements BaseSitecoreClient { * @param { DesignLibraryVariantGeneration} generation - The variant generation mode, if applicable * @returns {PageMode} The page mode */ + /** + * Applies media URL rewrite when rewriteMediaUrls is enabled. + * When true, uses default Edge host rewriter; when a function, transforms each string. + * @param {LayoutServiceData} layout - Layout data from layout/editing/component/error service + * @returns {LayoutServiceData} Rewritten layout (or same reference if rewrite disabled) + * @internal + */ + protected applyContentRewrite(layout: LayoutServiceData): LayoutServiceData { + const opt = this.initOptions.rewriteMediaUrls; + if (!opt) { + return layout; + } + const experienceEdgeUrl = resolveExperienceEdgeUrl(); + const transformer = + opt === true ? getDefaultMediaUrlTransformer(experienceEdgeUrl) : opt; + return applyMediaUrlRewrite(layout, transformer); + } + private getPageMode(mode: PageModeName, generation?: DesignLibraryVariantGeneration): PageMode { const pageMode: PageMode = { name: mode, diff --git a/packages/content/src/config/define-config.test.ts b/packages/content/src/config/define-config.test.ts index 887fb47dff..f25eeeccc8 100644 --- a/packages/content/src/config/define-config.test.ts +++ b/packages/content/src/config/define-config.test.ts @@ -5,7 +5,7 @@ import { deepMerge, defineConfig, getFallbackConfig } from './define-config'; import { SitecoreConfigInput } from './models'; import { SITECORE_CLI_MODE_ENV_VAR } from '../config-cli'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; describe('define-config', () => { const mockConfig: SitecoreConfigInput = { @@ -99,7 +99,7 @@ describe('define-config', () => { describe('getFallbackConfig', () => { it('populates env variables in fallback config', () => { process.env.SITECORE_EDGE_CONTEXT_ID = 'env-context'; - process.env.SITECORE_EDGE_URL = 'env-edge-url'; + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME = 'https://env-edge-url'; process.env.SITECORE_EDITING_SECRET = 'env-secret'; process.env.PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT = '111'; process.env.PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT = '222'; @@ -108,7 +108,7 @@ describe('define-config', () => { const cfg = getFallbackConfig(); expect(cfg.api.edge.contextId).to.equal('env-context'); - expect(cfg.api.edge.edgeUrl).to.equal('env-edge-url'); + expect(cfg.api.edge.edgeUrl).to.equal('https://env-edge-url'); expect(cfg.editingSecret).to.equal('env-secret'); expect(cfg.personalize.edgeTimeout).to.equal(111); expect(cfg.personalize.cdpTimeout).to.equal(222); @@ -118,7 +118,7 @@ describe('define-config', () => { it('falls back to defaults when env variables are absent', () => { delete process.env.SITECORE_EDGE_CONTEXT_ID; - delete process.env.SITECORE_EDGE_URL; + delete process.env.SITECORE_EDGE_PLATFORM_HOSTNAME; delete process.env.SITECORE_EDITING_SECRET; delete process.env.PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT; delete process.env.PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT; @@ -127,7 +127,7 @@ describe('define-config', () => { const cfg = getFallbackConfig(); expect(cfg.api.edge.contextId).to.equal(''); - expect(cfg.api.edge.edgeUrl).to.equal(SITECORE_EDGE_URL_DEFAULT); + expect(cfg.api.edge.edgeUrl).to.equal(SITECORE_EDGE_PLATFORM_URL_DEFAULT); expect(cfg.editingSecret).to.equal('editing-secret-missing'); expect(cfg.personalize.edgeTimeout).to.equal(400); expect(cfg.personalize.cdpTimeout).to.equal(400); diff --git a/packages/content/src/config/define-config.ts b/packages/content/src/config/define-config.ts index c9a2aea45b..92d9712a68 100644 --- a/packages/content/src/config/define-config.ts +++ b/packages/content/src/config/define-config.ts @@ -1,9 +1,8 @@ -import { constants, DefaultRetryStrategy } from '@sitecore-content-sdk/core'; +import { DefaultRetryStrategy } from '@sitecore-content-sdk/core'; +import { resolveEdgeUrl } from '@sitecore-content-sdk/core/tools'; import { DeepPartial, SitecoreConfig, SitecoreConfigInput } from './models'; import { SITECORE_CLI_MODE_ENV_VAR } from '../config-cli'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; - /** * Provides default initial values for SitecoreConfig * @returns default config @@ -13,7 +12,7 @@ export const getFallbackConfig = (): SitecoreConfig => ({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: '', - edgeUrl: process.env.SITECORE_EDGE_URL || SITECORE_EDGE_URL_DEFAULT, + edgeUrl: resolveEdgeUrl(), }, local: { apiKey: process.env.SITECORE_API_KEY || process.env.NEXT_PUBLIC_SITECORE_API_KEY || '', @@ -55,6 +54,7 @@ export const getFallbackConfig = (): SitecoreConfig => ({ timeout: 60, }, }, + rewriteMediaUrls: false, disableCodeGeneration: false, }); @@ -110,6 +110,10 @@ const resolveConfig = (base: SitecoreConfig, override: SitecoreConfigInput): Sit if (Number.isNaN(result.personalize.edgeTimeout) || !result.personalize.edgeTimeout) { result.personalize.edgeTimeout = base.personalize.edgeTimeout; } + // Resolve edge URL at config level so consumers use the resolved value directly + result.api.edge.edgeUrl = result.api.edge.edgeUrl + ? resolveEdgeUrl(result.api.edge.edgeUrl) + : resolveEdgeUrl(); return result; }; diff --git a/packages/content/src/config/models.ts b/packages/content/src/config/models.ts index 0b095652da..17acda07c3 100644 --- a/packages/content/src/config/models.ts +++ b/packages/content/src/config/models.ts @@ -203,6 +203,13 @@ export type SitecoreConfigInput = { */ locales?: string[]; }; + /** + * Rewrite media/content URLs in layout (media fields, rich text img/src, href, etc.). + * - When `true`: use default rewriter (Edge hostnames -> custom hostname from env). + * - When a function: transform each string value; the SDK traverses the layout for you. + * @default false + */ + rewriteMediaUrls?: boolean | ((value: string) => string); /** * Opt-out setting for code generation feature * Disables code extraction procedure diff --git a/packages/content/src/editing/component-layout-service.test.ts b/packages/content/src/editing/component-layout-service.test.ts index f03cbdee0e..47876018f6 100644 --- a/packages/content/src/editing/component-layout-service.test.ts +++ b/packages/content/src/editing/component-layout-service.test.ts @@ -8,7 +8,7 @@ import { ComponentLayoutRequestParams, ComponentLayoutService } from './componen import { LayoutServiceData } from '../layout/models'; import { DesignLibraryMode } from './models'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; use(spies); @@ -38,7 +38,7 @@ describe('ComponentLayoutService', () => { }); it('should fetch component data', () => { - nock(SITECORE_EDGE_URL_DEFAULT, { + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, 'content-type': 'application/json', @@ -60,7 +60,7 @@ describe('ComponentLayoutService', () => { }); it('should fetch component data in metadata mode', () => { - nock(SITECORE_EDGE_URL_DEFAULT, { + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, 'content-type': 'application/json', @@ -115,7 +115,7 @@ describe('ComponentLayoutService', () => { }, }; - nock(SITECORE_EDGE_URL_DEFAULT, { + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, 'content-type': 'application/json', @@ -139,7 +139,7 @@ describe('ComponentLayoutService', () => { }); it('should fetch component data with custom fetch options', () => { - nock(SITECORE_EDGE_URL_DEFAULT, { + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { my_header: 'my_value', sc_editMode: 'false', @@ -189,7 +189,7 @@ describe('ComponentLayoutService', () => { }); it('should catch 404 when request layout data', () => { - nock(SITECORE_EDGE_URL_DEFAULT, { + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, sc_editMode: 'false', @@ -224,7 +224,7 @@ describe('ComponentLayoutService', () => { }); it('should allow non 404 errors through', () => { - nock(SITECORE_EDGE_URL_DEFAULT, { + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, sc_editMode: 'false', diff --git a/packages/content/src/editing/component-layout-service.ts b/packages/content/src/editing/component-layout-service.ts index c55dd69d72..81a388af96 100644 --- a/packages/content/src/editing/component-layout-service.ts +++ b/packages/content/src/editing/component-layout-service.ts @@ -1,11 +1,9 @@ -import { NativeDataFetcher, constants, FetchOptions } from '@sitecore-content-sdk/core'; -import { resolveUrl } from '@sitecore-content-sdk/core/tools'; -import { LayoutServiceData } from '../layout/models'; +import { constants, NativeDataFetcher, FetchOptions } from '@sitecore-content-sdk/core'; +import { normalizeUrl, resolveUrl } from '@sitecore-content-sdk/core/tools'; +import { LayoutServiceData } from '../layout'; import debug from '../debug'; import { DesignLibraryMode, DesignLibraryVariantGeneration } from './models'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; - /** * Params for requesting component data in Design Library mode * @public @@ -143,9 +141,9 @@ export class ComponentLayoutService { * @returns {string} The fetch URL for the component data */ private getFetchUrl(params: ComponentLayoutRequestParams) { - return resolveUrl( - `${this.config.edgeUrl || SITECORE_EDGE_URL_DEFAULT}/layout/component`, - this.getComponentFetchParams(params) + const baseUrl = normalizeUrl( + this.config.edgeUrl ?? constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT ); + return resolveUrl(`${baseUrl}/layout/component`, this.getComponentFetchParams(params)); } } diff --git a/packages/content/src/editing/design-library.test.ts b/packages/content/src/editing/design-library.test.ts index b4eb3f707e..67c6f55d70 100644 --- a/packages/content/src/editing/design-library.test.ts +++ b/packages/content/src/editing/design-library.test.ts @@ -17,7 +17,7 @@ import testComponent from '../test-data/component-editing-data'; import { DesignLibraryMode } from './models'; import { ComponentRendering } from '../layout'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; describe('component library utils', () => { let debugSpy: sinon.SinonSpy; @@ -487,7 +487,7 @@ describe('component library utils', () => { it('should return the default design library script link when no URL is provided', () => { const scriptLink = getDesignLibraryScriptLink(); expect(scriptLink).to.equal( - `${SITECORE_EDGE_URL_DEFAULT}/v1/files/designlibrary/lib/rh-lib-script.js` + `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/designlibrary/lib/rh-lib-script.js` ); }); diff --git a/packages/content/src/editing/design-library.ts b/packages/content/src/editing/design-library.ts index e35069077f..9c720ec2fb 100644 --- a/packages/content/src/editing/design-library.ts +++ b/packages/content/src/editing/design-library.ts @@ -9,8 +9,6 @@ import { } from '../layout/models'; import { DesignLibraryMode } from './models'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; - /** * Event to be sent when report status to design library */ @@ -223,11 +221,14 @@ export function getDesignLibraryStatusEvent( /** * Generates the URL for the design library script link. - * @param {string} [sitecoreEdgeUrl] Sitecore Edge Platform URL. Default is https://edge-platform.sitecorecloud.io + * Caller should pass the resolved Edge URL from config. + * @param {string} [sitecoreEdgeUrl] Sitecore Edge Platform URL (resolved at config level). Defaults to platform URL. * @returns The full URL to the design library script. * @internal */ -export function getDesignLibraryScriptLink(sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT): string { +export function getDesignLibraryScriptLink( + sitecoreEdgeUrl: string = constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT +): string { return `${normalizeUrl(sitecoreEdgeUrl)}/v1/files/designlibrary/lib/rh-lib-script.js`; } diff --git a/packages/content/src/editing/editing-service.ts b/packages/content/src/editing/editing-service.ts index 2695e71c45..6f3d04fb52 100644 --- a/packages/content/src/editing/editing-service.ts +++ b/packages/content/src/editing/editing-service.ts @@ -100,14 +100,16 @@ export class EditingService { } ); - return { - layoutData: editingData?.item?.rendered || { - sitecore: { - context: { pageEditing: true, language }, - route: null, - }, + const layoutData = editingData?.item?.rendered || { + sitecore: { + context: { pageEditing: true, language }, + route: null, }, }; + + return { + layoutData, + }; } /** diff --git a/packages/content/src/layout/content-styles.test.ts b/packages/content/src/layout/content-styles.test.ts index 070c6b246c..bdfc29ef00 100644 --- a/packages/content/src/layout/content-styles.test.ts +++ b/packages/content/src/layout/content-styles.test.ts @@ -10,7 +10,7 @@ import { } from './content-styles'; import { ComponentRendering, Field, Item, LayoutServiceData } from './models'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; describe('content-styles', () => { const truthyValue = { value: '

bar

' }; @@ -92,7 +92,7 @@ describe('content-styles', () => { }; expect(getContentStylesheetLink(layoutData, sitecoreEdgeContextId)).to.deep.equal({ - href: `${SITECORE_EDGE_URL_DEFAULT}/v1/files/pages/styles/content-styles.css?sitecoreContextId=${sitecoreEdgeContextId}`, + href: `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/pages/styles/content-styles.css?sitecoreContextId=${sitecoreEdgeContextId}`, rel: 'stylesheet', }); }); @@ -352,7 +352,7 @@ describe('content-styles', () => { describe('getContentStylesheetUrl', () => { it('should return the default url', () => { expect(getContentStylesheetUrl(sitecoreEdgeContextId)).to.equal( - `${SITECORE_EDGE_URL_DEFAULT}/v1/files/pages/styles/content-styles.css?sitecoreContextId=${sitecoreEdgeContextId}` + `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/pages/styles/content-styles.css?sitecoreContextId=${sitecoreEdgeContextId}` ); }); diff --git a/packages/content/src/layout/content-styles.ts b/packages/content/src/layout/content-styles.ts index 6ad6c7e754..f12aff2fa6 100644 --- a/packages/content/src/layout/content-styles.ts +++ b/packages/content/src/layout/content-styles.ts @@ -1,10 +1,8 @@ -import { normalizeUrl } from '@sitecore-content-sdk/core/tools'; import { constants } from '@sitecore-content-sdk/core'; +import { normalizeUrl } from '@sitecore-content-sdk/core/tools'; import { ComponentRendering, Field, Item, LayoutServiceData, RouteData } from './index'; import { HTMLLink } from '../models'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; - /** * Regular expression to check if the content styles are used in the field value */ @@ -16,14 +14,14 @@ type Config = { loadStyles: boolean }; * Get the content styles link to be loaded from the Sitecore Edge Platform * @param {LayoutServiceData} layoutData Layout service data * @param {string} sitecoreEdgeContextId Sitecore Edge Context ID - * @param {string} [sitecoreEdgeUrl] Sitecore Edge Platform URL. Default is https://edge-platform.sitecorecloud.io + * @param {string} [sitecoreEdgeUrl] Sitecore Edge Platform URL (resolved at config level). Defaults to platform URL. * @returns {HTMLLink | null} content styles link, null if no styles are used in layout * @public */ export const getContentStylesheetLink = ( layoutData: LayoutServiceData, sitecoreEdgeContextId: string, - sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT + sitecoreEdgeUrl: string = constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT ): HTMLLink | null => { if (!layoutData.sitecore.route) return null; @@ -41,11 +39,9 @@ export const getContentStylesheetLink = ( export const getContentStylesheetUrl = ( sitecoreEdgeContextId: string, - sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT + sitecoreEdgeUrl: string = constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT ): string => - `${normalizeUrl( - sitecoreEdgeUrl - )}/v1/files/pages/styles/content-styles.css?sitecoreContextId=${sitecoreEdgeContextId}`; + `${normalizeUrl(sitecoreEdgeUrl)}/v1/files/pages/styles/content-styles.css?sitecoreContextId=${sitecoreEdgeContextId}`; export const traversePlaceholder = (components: Array, config: Config) => { if (config.loadStyles) return; diff --git a/packages/content/src/layout/index.ts b/packages/content/src/layout/index.ts index 53c1710c67..2c0d6b154d 100644 --- a/packages/content/src/layout/index.ts +++ b/packages/content/src/layout/index.ts @@ -35,3 +35,10 @@ export { getContentStylesheetLink } from './content-styles'; export { LayoutService, LayoutServiceConfig, GRAPHQL_LAYOUT_QUERY_NAME } from './layout-service'; export { getDesignLibraryStylesheetLinks } from './themes'; + +export { + rewriteEdgeHostInResponse, + containsDefaultEdgeHost, + getDefaultMediaUrlTransformer, + applyMediaUrlRewrite, +} from './rewrite-edge-host'; diff --git a/packages/content/src/layout/layout-service.ts b/packages/content/src/layout/layout-service.ts index 0478f94da3..8351604c18 100644 --- a/packages/content/src/layout/layout-service.ts +++ b/packages/content/src/layout/layout-service.ts @@ -51,11 +51,12 @@ export class LayoutService extends SitecoreServiceBase { }>(query, {}, fetchOptions); // If `rendered` is empty -> not found - return ( + const layoutData = data?.layout?.item?.rendered || { sitecore: { context: { pageEditing: false, language: routeOptions?.locale }, route: null }, - } - ); + }; + + return layoutData; } /** diff --git a/packages/content/src/layout/rewrite-edge-host.test.ts b/packages/content/src/layout/rewrite-edge-host.test.ts new file mode 100644 index 0000000000..23e4c093f1 --- /dev/null +++ b/packages/content/src/layout/rewrite-edge-host.test.ts @@ -0,0 +1,241 @@ +/* eslint-disable no-unused-expressions */ +import { expect } from 'chai'; +import { constants } from '@sitecore-content-sdk/core'; +import { rewriteEdgeHostInResponse, containsDefaultEdgeHost } from './rewrite-edge-host'; + +const DEFAULT_EDGE_URL = constants.SITECORE_EXPERIENCE_EDGE_URL_DEFAULT; +const CUSTOM_EDGE_URL = 'https://custom.example.com'; + +describe('rewriteEdgeHostInResponse', () => { + describe('rewriteEdgeHostInResponse()', () => { + it('should return response unchanged when default edge URL is passed', () => { + const response = { + url: 'https://edge-platform.sitecorecloud.io/media/image.jpg', + }; + const result = rewriteEdgeHostInResponse(response, DEFAULT_EDGE_URL); + expect(result).to.deep.equal(response); + }); + + it('should rewrite when edgeUrl is provided from config (custom hostname)', () => { + const response = { + url: 'https://edge.sitecorecloud.io/media/image.jpg', + }; + const result = rewriteEdgeHostInResponse(response, 'https://custom.edge.example.com'); + expect(result.url).to.equal('https://custom.edge.example.com/media/image.jpg'); + }); + + it('should not rewrite edge-platform.sitecorecloud.io (only default hostname is rewritten)', () => { + const response = { + url: 'https://edge-platform.sitecorecloud.io/media/image.jpg', + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.url).to.equal('https://edge-platform.sitecorecloud.io/media/image.jpg'); + }); + + it('should rewrite edge.sitecorecloud.io in string values', () => { + const response = { + url: 'https://edge.sitecorecloud.io/media/image.jpg', + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.url).to.equal('https://custom.example.com/media/image.jpg'); + }); + + it('should not rewrite edge-staging.sitecore-staging.cloud (only default hostname is rewritten)', () => { + const response = { + url: 'https://edge-staging.sitecore-staging.cloud/tenant-id/media/image.jpg', + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.url).to.equal( + 'https://edge-staging.sitecore-staging.cloud/tenant-id/media/image.jpg' + ); + }); + + it('should not rewrite edge-platform-staging.sitecore-staging.cloud (only default hostname is rewritten)', () => { + const response = { + url: 'https://edge-platform-staging.sitecore-staging.cloud/tenant-id/media/image.jpg', + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.url).to.equal( + 'https://edge-platform-staging.sitecore-staging.cloud/tenant-id/media/image.jpg' + ); + }); + + it('should rewrite multiple occurrences in a string', () => { + const response = { + html: '', + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.html).to.equal( + '' + ); + }); + + it('should rewrite nested objects', () => { + const response = { + sitecore: { + context: {}, + route: { + fields: { + image: { + value: { + src: 'https://edge.sitecorecloud.io/media/image.jpg', + }, + }, + }, + }, + }, + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.sitecore.route.fields.image.value.src).to.equal( + 'https://custom.example.com/media/image.jpg' + ); + }); + + it('should rewrite arrays', () => { + const response = { + urls: [ + 'https://edge.sitecorecloud.io/a.jpg', + 'https://edge.sitecorecloud.io/b.jpg', + ], + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.urls).to.deep.equal([ + 'https://custom.example.com/a.jpg', + 'https://custom.example.com/b.jpg', + ]); + }); + + it('should handle null values', () => { + const response = { + value: null, + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.value).to.be.null; + }); + + it('should handle undefined values', () => { + const response = { + value: undefined, + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.value).to.be.undefined; + }); + + it('should preserve non-string primitives', () => { + const response = { + number: 42, + boolean: true, + string: 'no edge url here', + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.number).to.equal(42); + expect(result.boolean).to.be.true; + expect(result.string).to.equal('no edge url here'); + }); + + it('should handle http protocol in edge URLs', () => { + const response = { + url: 'http://edge.sitecorecloud.io/media/image.jpg', + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.url).to.equal('https://custom.example.com/media/image.jpg'); + }); + + it('should handle mixed case (case insensitive)', () => { + const response = { + url: 'https://EDGE.SITECORECLOUD.IO/media/image.jpg', + }; + const result = rewriteEdgeHostInResponse(response, CUSTOM_EDGE_URL); + expect(result.url).to.equal('https://custom.example.com/media/image.jpg'); + }); + + it('should handle complex layout service data structure', () => { + const layoutData = { + sitecore: { + context: { + pageEditing: false, + language: 'en', + }, + route: { + name: 'Home', + placeholders: { + main: [ + { + componentName: 'Image', + fields: { + image: { + value: { + src: 'https://edge.sitecorecloud.io/-/media/image.jpg', + alt: 'Test image', + }, + }, + }, + }, + { + componentName: 'RichText', + fields: { + content: { + value: + '

Image:

', + }, + }, + }, + ], + }, + }, + }, + }; + + const result = rewriteEdgeHostInResponse(layoutData, CUSTOM_EDGE_URL); + + expect(result.sitecore.route.placeholders.main[0].fields.image.value.src).to.equal( + 'https://custom.example.com/-/media/image.jpg' + ); + expect(result.sitecore.route.placeholders.main[1].fields.content.value).to.equal( + '

Image:

' + ); + }); + + it('should not rewrite similar but non-Edge URLs (no false positives)', () => { + const layout = { + a: 'https://other-cdn.com/path/edge-platform/image.jpg', + b: 'https://my-edge-store.example.com/media/file.jpg', + c: 'https://edge-platform.sitecorecloud.io/real-edge/media.jpg', + }; + + const result = rewriteEdgeHostInResponse(layout, CUSTOM_EDGE_URL) as typeof layout; + + expect(result.a).to.equal('https://other-cdn.com/path/edge-platform/image.jpg'); + expect(result.b).to.equal('https://my-edge-store.example.com/media/file.jpg'); + expect(result.c).to.equal( + 'https://edge-platform.sitecorecloud.io/real-edge/media.jpg' + ); + }); + }); + + describe('containsDefaultEdgeHost()', () => { + it('should return false for edge-platform.sitecorecloud.io (only default hostname matches)', () => { + expect( + containsDefaultEdgeHost('https://edge-platform.sitecorecloud.io/media/image.jpg') + ).to.be.false; + }); + + it('should return true for edge.sitecorecloud.io (default hostname)', () => { + expect(containsDefaultEdgeHost('https://edge.sitecorecloud.io/media/image.jpg')).to.be.true; + }); + + it('should return false for edge-staging.sitecore-staging.cloud (only default hostname matches)', () => { + expect( + containsDefaultEdgeHost('https://edge-staging.sitecore-staging.cloud/tenant/media/a.jpg') + ).to.be.false; + }); + + it('should return false for custom hostname', () => { + expect(containsDefaultEdgeHost('https://custom.example.com/media/image.jpg')).to.be.false; + }); + + it('should return false for empty string', () => { + expect(containsDefaultEdgeHost('')).to.be.false; + }); + }); +}); diff --git a/packages/content/src/layout/rewrite-edge-host.ts b/packages/content/src/layout/rewrite-edge-host.ts new file mode 100644 index 0000000000..4a78524b68 --- /dev/null +++ b/packages/content/src/layout/rewrite-edge-host.ts @@ -0,0 +1,175 @@ +import { constants } from '@sitecore-content-sdk/core'; +import { normalizeUrl } from '@sitecore-content-sdk/core/tools'; + +/** Default Edge hostname derived from the default Edge URL (edge.sitecorecloud.io). @internal */ +const DEFAULT_EDGE_HOSTNAME = new URL(constants.SITECORE_EXPERIENCE_EDGE_URL_DEFAULT).hostname; + +/** + * Returns true if the given URL has a custom (non-default) Edge hostname. + * @param {string} url - Full URL or hostname + * @returns {boolean} True if URL host is not the default Edge hostname + * @internal + */ +function isCustomEdgeUrl(url: string): boolean { + try { + const u = url.startsWith('http') ? new URL(url) : new URL(`https://${url}`); + const host = u.hostname.toLowerCase(); + return host !== DEFAULT_EDGE_HOSTNAME; + } catch { + return false; + } +} + +/** + * Regular expression for matching the default Edge hostname in URLs. + * Matches both http:// and https:// protocols. + * @internal + */ +const EDGE_HOST_PATTERN = new RegExp( + `https?://${escapeRegExp(DEFAULT_EDGE_HOSTNAME)}`, + 'gi' +); + +/** + * Escapes a string so it can be safely embedded in a RegExp as a literal. + * @param {string} input - The string to escape + * @returns {string} The escaped string safe for RegExp construction + * @internal + */ +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Rewrites Experience Edge hostnames in a response object to use the custom hostname. + * This function performs a deep traversal of the object and replaces any string values + * containing the default Experience Edge hostname with the custom hostname. + * Caller should pass the Experience Edge URL (e.g. from resolveExperienceEdgeUrl()). + * + * Use case: Experience Edge returns Layout Service output (layout, placeholders, component fields). + * Field values can contain URLs with the Edge hostname—e.g. Image field `value.src` + * (`https://edge.sitecorecloud.io/-/media/...`), Rich Text HTML (``), + * or link `href`. When using a custom hostname (e.g. CDN in front of Edge), these URLs + * must be rewritten so layout API and media requests both go through the custom host. + * @param {T} response - The response object to process (typically LayoutServiceData) + * @param {string} edgeUrl - Experience Edge URL to rewrite to (e.g. from resolveExperienceEdgeUrl). + * @returns {T} The response object with Experience Edge hostnames rewritten (same reference if no custom hostname) + * @public + * @example + * const layout = await layoutService.fetchLayoutData(path, options); + * const rewritten = rewriteEdgeHostInResponse(layout, resolveExperienceEdgeUrl()); + */ +export function rewriteEdgeHostInResponse(response: T, edgeUrl: string): T { + const customEdgeUrl = normalizeUrl(edgeUrl); + if (!isCustomEdgeUrl(customEdgeUrl)) { + return response; + } + return deepRewriteEdgeHost(response, customEdgeUrl); +} + +/** + * Recursively traverses an object/array and rewrites Edge hostnames in string values. + * @param {T} value - The value to process + * @param {string} customEdgeUrl - The custom Edge URL to replace with + * @returns {T} The processed value with Edge hostnames replaced + * @internal + */ +function deepRewriteEdgeHost(value: T, customEdgeUrl: string): T { + // Handle null/undefined + if (value === null || value === undefined) { + return value; + } + + // Handle strings - perform the actual replacement + if (typeof value === 'string') { + return rewriteEdgeHostInString(value, customEdgeUrl) as T; + } + + // Handle arrays + if (Array.isArray(value)) { + return value.map((item) => deepRewriteEdgeHost(item, customEdgeUrl)) as T; + } + + // Handle plain objects + if (typeof value === 'object') { + // Skip non-plain objects (Date, RegExp, etc.) + if (Object.getPrototypeOf(value) !== Object.prototype) { + return value; + } + + const result: Record = {}; + for (const key of Object.keys(value as Record)) { + result[key] = deepRewriteEdgeHost((value as Record)[key], customEdgeUrl); + } + return result as T; + } + + // Return primitives (numbers, booleans) unchanged + return value; +} + +/** + * Replaces Experience Edge hostnames in a string with the custom hostname. + * @param {string} str - The string to process + * @param {string} customEdgeUrl - The custom Experience Edge URL to replace with + * @returns {string} The string with Experience Edge hostnames replaced + * @internal + */ +function rewriteEdgeHostInString(str: string, customEdgeUrl: string): string { + EDGE_HOST_PATTERN.lastIndex = 0; + return str.replace(EDGE_HOST_PATTERN, customEdgeUrl); +} + +/** + * Returns the default media URL transformer: rewrites Experience Edge hostnames when custom hostname is configured. + * @param {string} edgeUrl - Experience Edge URL to rewrite to (e.g. from resolveExperienceEdgeUrl()). + * @returns {(value: string) => string} Transformer function; returns string unchanged when no custom hostname + * @internal + */ +export function getDefaultMediaUrlTransformer(edgeUrl: string): (value: string) => string { + const customEdgeUrl = normalizeUrl(edgeUrl); + if (!isCustomEdgeUrl(customEdgeUrl)) { + return (s) => s; + } + return (s) => rewriteEdgeHostInString(s, customEdgeUrl); +} + +/** + * Deeply traverses a value and applies a string transformer to every string. + * @param {T} value - Value to process (layout, object, array, string) + * @param {(s: string) => string} transform - Function that transforms each string + * @returns {T} New value with transformed strings + * @internal + */ +export function applyMediaUrlRewrite(value: T, transform: (s: string) => string): T { + if (value === null || value === undefined) { + return value; + } + if (typeof value === 'string') { + return transform(value) as T; + } + if (Array.isArray(value)) { + return value.map((item) => applyMediaUrlRewrite(item, transform)) as T; + } + if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + const result: Record = {}; + for (const key of Object.keys(value as Record)) { + result[key] = applyMediaUrlRewrite( + (value as Record)[key], + transform + ); + } + return result as T; + } + return value; +} + +/** + * Checks if a string contains the default Edge hostname (from the default Edge URL). + * @param {string} str - The string to check + * @returns {boolean} True if the string contains the default Edge hostname + * @public + */ +export function containsDefaultEdgeHost(str: string): boolean { + return str.includes(DEFAULT_EDGE_HOSTNAME); +} diff --git a/packages/content/src/layout/themes.test.ts b/packages/content/src/layout/themes.test.ts index 97ded41a93..04d5a12a93 100644 --- a/packages/content/src/layout/themes.test.ts +++ b/packages/content/src/layout/themes.test.ts @@ -3,7 +3,7 @@ import { constants } from '@sitecore-content-sdk/core'; import { getDesignLibraryStylesheetLinks, getStylesheetUrl } from './themes'; import { ComponentRendering } from '.'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; describe('themes', () => { const sitecoreEdgeContextId = 'test'; @@ -425,7 +425,7 @@ describe('themes', () => { describe('getStylesheetUrl', () => { it('should use prod edge url by default', () => { expect(getStylesheetUrl('foo', sitecoreEdgeContextId)).to.equal( - `${SITECORE_EDGE_URL_DEFAULT}/v1/files/components/styles/foo.css?sitecoreContextId=${sitecoreEdgeContextId}` + `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/v1/files/components/styles/foo.css?sitecoreContextId=${sitecoreEdgeContextId}` ); }); diff --git a/packages/content/src/layout/themes.ts b/packages/content/src/layout/themes.ts index 643d4b8ef7..81db7a78b0 100644 --- a/packages/content/src/layout/themes.ts +++ b/packages/content/src/layout/themes.ts @@ -1,10 +1,8 @@ -import { normalizeUrl } from '@sitecore-content-sdk/core/tools'; import { constants } from '@sitecore-content-sdk/core'; +import { normalizeUrl } from '@sitecore-content-sdk/core/tools'; import { ComponentRendering, LayoutServiceData, RouteData, getFieldValue } from '.'; import { HTMLLink } from '../models'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; - /** * Pattern for library ids * @example -library--foo @@ -15,14 +13,14 @@ const STYLES_LIBRARY_ID_REGEX = /-library--([^\s]+)/; * Walks through rendering tree and returns list of links of all FEAAS, BYOC or SXA Design Library Stylesheets that are used * @param {LayoutServiceData} layoutData Layout service data * @param {string} sitecoreEdgeContextId Sitecore Edge Context ID - * @param {string} [sitecoreEdgeUrl] Sitecore Edge Platform URL. Default is https://edge-platform.sitecorecloud.io + * @param {string} [sitecoreEdgeUrl] Sitecore Edge Platform URL (resolved at config level). Defaults to platform URL. * @returns {HTMLLink[]} library stylesheet links * @public */ export function getDesignLibraryStylesheetLinks( layoutData: LayoutServiceData, sitecoreEdgeContextId: string, - sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT + sitecoreEdgeUrl: string = constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT ): HTMLLink[] { const ids = new Set(); @@ -39,12 +37,9 @@ export function getDesignLibraryStylesheetLinks( export const getStylesheetUrl = ( id: string, sitecoreEdgeContextId: string, - sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT -) => { - return `${normalizeUrl( - sitecoreEdgeUrl - )}/v1/files/components/styles/${id}.css?sitecoreContextId=${sitecoreEdgeContextId}`; -}; + sitecoreEdgeUrl: string = constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT +) => + `${normalizeUrl(sitecoreEdgeUrl)}/v1/files/components/styles/${id}.css?sitecoreContextId=${sitecoreEdgeContextId}`; /** * Traverse placeholder and components to add library ids diff --git a/packages/content/src/site/error-pages-service.ts b/packages/content/src/site/error-pages-service.ts index 0be95b5cdf..97b1bbc5bd 100644 --- a/packages/content/src/site/error-pages-service.ts +++ b/packages/content/src/site/error-pages-service.ts @@ -46,9 +46,17 @@ export interface ErrorPagesServiceConfig extends GraphQLServiceConfig { * @public */ export type ErrorPages = { - notFoundPage: { rendered: LayoutServiceData }; + /** + * Rendered 404 page layout. + * Can be null if the site has no error handling configured for the requested language. + */ + notFoundPage: { rendered: LayoutServiceData } | null; notFoundPagePath: string; - serverErrorPage: { rendered: LayoutServiceData }; + /** + * Rendered 500 page layout. + * Can be null if the site has no error handling configured for the requested language. + */ + serverErrorPage: { rendered: LayoutServiceData } | null; serverErrorPagePath: string; }; @@ -105,9 +113,23 @@ export class ErrorPagesService { }, fetchOptions )) - .then((result: ErrorPagesQueryResult) => - result.site.siteInfo ? result.site.siteInfo.errorHandling : null - ) + .then((result: ErrorPagesQueryResult) => { + if (!result.site.siteInfo) return null; + + const errorHandling = result.site.siteInfo.errorHandling; + const notFoundPage = errorHandling.notFoundPage?.rendered + ? { rendered: errorHandling.notFoundPage.rendered } + : null; + const serverErrorPage = errorHandling.serverErrorPage?.rendered + ? { rendered: errorHandling.serverErrorPage.rendered } + : null; + + return { + ...errorHandling, + notFoundPage, + serverErrorPage, + }; + }) .catch((e) => Promise.reject(e)); } diff --git a/packages/content/src/tools/codegen/component-generation.test.ts b/packages/content/src/tools/codegen/component-generation.test.ts index bfd97fe960..4f159ea282 100644 --- a/packages/content/src/tools/codegen/component-generation.test.ts +++ b/packages/content/src/tools/codegen/component-generation.test.ts @@ -7,7 +7,7 @@ import { getComponentSpec, } from './component-generation'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; describe('component-generation', () => { const token = '456'; @@ -21,7 +21,7 @@ describe('component-generation', () => { }); expect(url).to.equal( - `${SITECORE_EDGE_URL_DEFAULT}/authoring/api/v1/components/generated/123?token=456&targetPath=.%2Fcomponents%2Fpromo-block%2FPromoBlock.variantA.ts` + `${SITECORE_EDGE_PLATFORM_URL_DEFAULT}/authoring/api/v1/components/generated/123?token=456&targetPath=.%2Fcomponents%2Fpromo-block%2FPromoBlock.variantA.ts` ); }); @@ -41,7 +41,7 @@ describe('component-generation', () => { describe('getComponentSpec', () => { const mockComponentSpecApi = ({ - edgeUrl = SITECORE_EDGE_URL_DEFAULT, + edgeUrl = SITECORE_EDGE_PLATFORM_URL_DEFAULT, componentId, targetPath, token, diff --git a/packages/content/src/tools/codegen/component-generation.ts b/packages/content/src/tools/codegen/component-generation.ts index e9961b8fee..bcae335e2f 100644 --- a/packages/content/src/tools/codegen/component-generation.ts +++ b/packages/content/src/tools/codegen/component-generation.ts @@ -1,6 +1,6 @@ import { constants, debug } from '@sitecore-content-sdk/core'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; /** * The parameters for fetching the component spec. @@ -46,7 +46,7 @@ export interface ComponentSpec { */ export const getComponentSpecUrl = ({ componentId, - edgeUrl = SITECORE_EDGE_URL_DEFAULT, + edgeUrl = SITECORE_EDGE_PLATFORM_URL_DEFAULT, targetPath, token, }: GetComponentSpecParams) => { @@ -67,7 +67,7 @@ export const getComponentSpecUrl = ({ */ export const getComponentSpec = async ({ componentId, - edgeUrl = SITECORE_EDGE_URL_DEFAULT, + edgeUrl = SITECORE_EDGE_PLATFORM_URL_DEFAULT, targetPath, token, }: GetComponentSpecParams) => { diff --git a/packages/content/src/tools/codegen/extract-files.test.ts b/packages/content/src/tools/codegen/extract-files.test.ts index 5b89ba84ab..a0dbd46624 100644 --- a/packages/content/src/tools/codegen/extract-files.test.ts +++ b/packages/content/src/tools/codegen/extract-files.test.ts @@ -11,7 +11,7 @@ import { extractFiles, ExtractFilesConfig } from './extract-files'; import nock from 'nock'; import { constants } from '@sitecore-content-sdk/core'; -const { SITECORE_EDGE_URL_DEFAULT } = constants; +const { SITECORE_EDGE_PLATFORM_URL_DEFAULT } = constants; describe('extract-files', () => { const RENDERINGHOST_NAME = 'testRenderingHost'; @@ -233,7 +233,7 @@ describe('extract-files', () => { const consoleLogStub = sandbox.stub(console, 'log'); - nock(SITECORE_EDGE_URL_DEFAULT) + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT) .post('/mesh/push/api/v1/contentsdk/code/extracted') .reply(200) .persist(); @@ -265,7 +265,7 @@ describe('extract-files', () => { const consoleLogStub = sandbox.stub(console, 'log'); - nock(SITECORE_EDGE_URL_DEFAULT) + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT) .post('/mesh/push/api/v1/contentsdk/code/extracted') .reply(200) .persist(); @@ -297,7 +297,7 @@ describe('extract-files', () => { const consoleLogStub = sandbox.stub(console, 'log'); - nock(SITECORE_EDGE_URL_DEFAULT) + nock(SITECORE_EDGE_PLATFORM_URL_DEFAULT) .post('/mesh/push/api/v1/contentsdk/code/extracted') .reply(200) .persist(); diff --git a/packages/core/api/content-sdk-core.api.md b/packages/core/api/content-sdk-core.api.md index eede997074..a389430c76 100644 --- a/packages/core/api/content-sdk-core.api.md +++ b/packages/core/api/content-sdk-core.api.md @@ -38,7 +38,8 @@ export { ClientError } declare namespace constants { export { - SITECORE_EDGE_URL_DEFAULT, + SITECORE_EDGE_PLATFORM_URL_DEFAULT, + SITECORE_EXPERIENCE_EDGE_URL_DEFAULT, CLAIMS, DEFAULT_SITECORE_AUTH_DOMAIN, DEFAULT_SITECORE_AUTH_AUDIENCE, @@ -257,6 +258,15 @@ export interface NativeDataFetcherResponse { // @public export const normalizeUrl: (url: string) => string; +// @public +export function resolveEdgeUrl(edgeUrl?: string): string; + +// @public +export function resolveEdgeUrlForStaticFiles(): string; + +// @public +export function resolveExperienceEdgeUrl(): string; + // @public export function resolveUrl(urlBase: string, params?: ParsedUrlQueryInput): string; @@ -269,8 +279,17 @@ export interface RetryStrategy { // @internal export function setCache(key: string, data: unknown): void; +// @public +export const SITECORE_EDGE_PLATFORM_HOSTNAME_ENV = "SITECORE_EDGE_PLATFORM_HOSTNAME"; + +// @internal +const SITECORE_EDGE_PLATFORM_URL_DEFAULT = "https://edge-platform.sitecorecloud.io"; + +// @public +export const SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV = "SITECORE_EXPERIENCE_EDGE_HOSTNAME"; + // @internal -const SITECORE_EDGE_URL_DEFAULT = "https://edge-platform.sitecorecloud.io"; +const SITECORE_EXPERIENCE_EDGE_URL_DEFAULT = "https://edge.sitecorecloud.io"; // @public export interface TenantArgs { @@ -285,7 +304,7 @@ export interface TenantArgs { // Warnings were encountered during analysis: // -// src/tools/index.ts:31:3 - (ae-forgotten-export) The symbol "authModule" needs to be exported by the entry point api-surface.d.ts +// src/tools/index.ts:38:3 - (ae-forgotten-export) The symbol "authModule" needs to be exported by the entry point api-surface.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index d2cd828fb6..07082d485b 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1,8 +1,16 @@ /** - * Default Sitecore edge URL + * Default Edge Platform URL (edge-platform.sitecorecloud.io). Used for service endpoints + * (GraphQL, content API, forms, layout, static files) when no custom hostname is configured. * @internal */ -export const SITECORE_EDGE_URL_DEFAULT = 'https://edge-platform.sitecorecloud.io'; +export const SITECORE_EDGE_PLATFORM_URL_DEFAULT = 'https://edge-platform.sitecorecloud.io'; + +/** + * Default Experience Edge URL (edge.sitecorecloud.io). Used only when replacing media URLs + * in layout/editing responses (rewrite-edge-host). Do not use for service endpoints. + * @internal + */ +export const SITECORE_EXPERIENCE_EDGE_URL_DEFAULT = 'https://edge.sitecorecloud.io'; /** * Claims URL diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 3518efc6f0..b65e9e2ec8 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -6,6 +6,13 @@ export * from './metadata'; export { default as isServer } from './is-server'; export { ensurePathExists } from './ensurePath'; export { normalizeUrl } from './normalize-url'; +export { + resolveEdgeUrl, + resolveEdgeUrlForStaticFiles, + resolveExperienceEdgeUrl, + SITECORE_EDGE_PLATFORM_HOSTNAME_ENV, + SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV, +} from './resolve-edge-url'; export { resolveUrl, isTimeoutError, diff --git a/packages/core/src/tools/normalize-env-value.test.ts b/packages/core/src/tools/normalize-env-value.test.ts new file mode 100644 index 0000000000..b4fc92d955 --- /dev/null +++ b/packages/core/src/tools/normalize-env-value.test.ts @@ -0,0 +1,51 @@ +/* eslint-disable no-unused-expressions */ +import { expect } from 'chai'; +import { normalizeEnvValue } from './normalize-env-value'; + +describe('normalizeEnvValue', () => { + it('should return undefined for undefined', () => { + expect(normalizeEnvValue(undefined)).to.be.undefined; + }); + + it('should return undefined for empty string', () => { + expect(normalizeEnvValue('')).to.be.undefined; + }); + + it('should return undefined for whitespace-only string', () => { + expect(normalizeEnvValue(' ')).to.be.undefined; + expect(normalizeEnvValue('\t\n')).to.be.undefined; + }); + + it('should return undefined for the string "undefined"', () => { + expect(normalizeEnvValue('undefined')).to.be.undefined; + }); + + it('should return undefined for the string "null"', () => { + expect(normalizeEnvValue('null')).to.be.undefined; + }); + + it('should treat "undefined" and "null" case-insensitively', () => { + expect(normalizeEnvValue('UNDEFINED')).to.be.undefined; + expect(normalizeEnvValue('Undefined')).to.be.undefined; + expect(normalizeEnvValue('NULL')).to.be.undefined; + expect(normalizeEnvValue('Null')).to.be.undefined; + }); + + it('should return undefined when trimmed value is "undefined" or "null"', () => { + expect(normalizeEnvValue(' undefined ')).to.be.undefined; + expect(normalizeEnvValue(' null ')).to.be.undefined; + }); + + it('should return trimmed string for valid values', () => { + expect(normalizeEnvValue('custom.example.com')).to.equal('custom.example.com'); + expect(normalizeEnvValue(' custom.example.com ')).to.equal('custom.example.com'); + }); + + it('should preserve case of valid values', () => { + expect(normalizeEnvValue('My-Host.example.com')).to.equal('My-Host.example.com'); + }); + + it('should return valid value that contains "undefined" as substring', () => { + expect(normalizeEnvValue('my-undefined-host.com')).to.equal('my-undefined-host.com'); + }); +}); diff --git a/packages/core/src/tools/normalize-env-value.ts b/packages/core/src/tools/normalize-env-value.ts new file mode 100644 index 0000000000..9b033bcc32 --- /dev/null +++ b/packages/core/src/tools/normalize-env-value.ts @@ -0,0 +1,19 @@ +/** + * Normalizes values that may come from environment variables. + * In Node, setting `process.env.FOO = undefined` results in the string 'undefined', + * which should be treated as if the variable is not set. + * @param {string | undefined} value - Possibly undefined env-like value + * @returns {string | undefined} A usable string value, or undefined if not meaningful + * @internal + */ +export function normalizeEnvValue(value: string | undefined): string | undefined { + if (!value) return undefined; + + const trimmed = value.trim(); + if (!trimmed) return undefined; + + const lowered = trimmed.toLowerCase(); + if (lowered === 'undefined' || lowered === 'null') return undefined; + + return trimmed; +} diff --git a/packages/core/src/tools/resolve-edge-url.test.ts b/packages/core/src/tools/resolve-edge-url.test.ts new file mode 100644 index 0000000000..55a4adcf75 --- /dev/null +++ b/packages/core/src/tools/resolve-edge-url.test.ts @@ -0,0 +1,137 @@ +/* eslint-disable no-unused-expressions */ +import { expect } from 'chai'; +import { + resolveEdgeUrl, + resolveEdgeUrlForStaticFiles, + resolveExperienceEdgeUrl, + SITECORE_EDGE_PLATFORM_HOSTNAME_ENV, + SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV, +} from './resolve-edge-url'; +import { + SITECORE_EDGE_PLATFORM_URL_DEFAULT, + SITECORE_EXPERIENCE_EDGE_URL_DEFAULT, +} from '../constants'; + +describe('resolveEdgeUrl', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV]; + delete process.env[SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV]; + }); + + afterEach(() => { + // Restore original env + process.env = { ...originalEnv }; + }); + + describe('resolveEdgeUrl()', () => { + it('should return explicit edgeUrl parameter when provided', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'custom.example.com'; + const result = resolveEdgeUrl('https://explicit.example.com'); + expect(result).to.equal('https://explicit.example.com'); + }); + + it('should normalize trailing slash from explicit edgeUrl', () => { + const result = resolveEdgeUrl('https://explicit.example.com/'); + expect(result).to.equal('https://explicit.example.com'); + }); + + it('should use SITECORE_EDGE_PLATFORM_HOSTNAME when set (hostname only)', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'my-tenant.edge.example.com'; + const result = resolveEdgeUrl(); + expect(result).to.equal('https://my-tenant.edge.example.com'); + }); + + it('should use SITECORE_EDGE_PLATFORM_HOSTNAME when set (full URL)', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'https://my-tenant.edge.example.com'; + const result = resolveEdgeUrl(); + expect(result).to.equal('https://my-tenant.edge.example.com'); + }); + + it('should normalize trailing slash from hostname env var', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'https://my-tenant.edge.example.com/'; + const result = resolveEdgeUrl(); + expect(result).to.equal('https://my-tenant.edge.example.com'); + }); + + it('should trim whitespace from hostname env var', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = ' my-tenant.edge.example.com '; + const result = resolveEdgeUrl(); + expect(result).to.equal('https://my-tenant.edge.example.com'); + }); + + it('should use SITECORE_EDGE_PLATFORM_HOSTNAME when set', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'public-tenant.edge.example.com'; + const result = resolveEdgeUrl(); + expect(result).to.equal('https://public-tenant.edge.example.com'); + }); + + it('should return default when no env vars are set', () => { + const result = resolveEdgeUrl(); + expect(result).to.equal(SITECORE_EDGE_PLATFORM_URL_DEFAULT); + }); + + it('should treat the string "undefined" as an unset hostname env var', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'undefined'; + const result = resolveEdgeUrl(); + expect(result).to.equal(SITECORE_EDGE_PLATFORM_URL_DEFAULT); + }); + + it('should treat the string "null" as an unset hostname env var', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'null'; + const result = resolveEdgeUrl(); + expect(result).to.equal(SITECORE_EDGE_PLATFORM_URL_DEFAULT); + }); + + it('should treat whitespace-only hostname env var as unset', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = ' '; + const result = resolveEdgeUrl(); + expect(result).to.equal(SITECORE_EDGE_PLATFORM_URL_DEFAULT); + }); + + it('should handle http protocol in hostname', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'http://insecure.example.com'; + const result = resolveEdgeUrl(); + expect(result).to.equal('http://insecure.example.com'); + }); + }); + + describe('resolveExperienceEdgeUrl()', () => { + it('should return default Experience Edge URL when env is not set', () => { + const result = resolveExperienceEdgeUrl(); + expect(result).to.equal(SITECORE_EXPERIENCE_EDGE_URL_DEFAULT); + }); + + it('should return custom URL when SITECORE_EXPERIENCE_EDGE_HOSTNAME is set (hostname)', () => { + process.env[SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV] = 'media.example.com'; + const result = resolveExperienceEdgeUrl(); + expect(result).to.equal('https://media.example.com'); + }); + + it('should return custom URL when SITECORE_EXPERIENCE_EDGE_HOSTNAME is set (full URL)', () => { + process.env[SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV] = 'https://media.example.com'; + const result = resolveExperienceEdgeUrl(); + expect(result).to.equal('https://media.example.com'); + }); + + it('should not be affected by SITECORE_EDGE_PLATFORM_HOSTNAME', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'platform.example.com'; + const result = resolveExperienceEdgeUrl(); + expect(result).to.equal(SITECORE_EXPERIENCE_EDGE_URL_DEFAULT); + }); + }); + + describe('resolveEdgeUrlForStaticFiles()', () => { + it('should return default Edge URL', () => { + const result = resolveEdgeUrlForStaticFiles(); + expect(result).to.equal(SITECORE_EDGE_PLATFORM_URL_DEFAULT); + }); + + it('should return default even when custom hostname is set', () => { + process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] = 'custom.example.com'; + const result = resolveEdgeUrlForStaticFiles(); + expect(result).to.equal(SITECORE_EDGE_PLATFORM_URL_DEFAULT); + }); + }); +}); diff --git a/packages/core/src/tools/resolve-edge-url.ts b/packages/core/src/tools/resolve-edge-url.ts new file mode 100644 index 0000000000..93e0934eb9 --- /dev/null +++ b/packages/core/src/tools/resolve-edge-url.ts @@ -0,0 +1,105 @@ +import { + SITECORE_EDGE_PLATFORM_URL_DEFAULT, + SITECORE_EXPERIENCE_EDGE_URL_DEFAULT, +} from '../constants'; +import { normalizeEnvValue } from './normalize-env-value'; +import { normalizeUrl } from './normalize-url'; + +/** + * Environment variable name for the custom Edge Platform hostname (framework-agnostic). + * Used for service endpoints (GraphQL, APIs, forms). Server-side only. + * @public + */ +export const SITECORE_EDGE_PLATFORM_HOSTNAME_ENV = 'SITECORE_EDGE_PLATFORM_HOSTNAME'; + +/** + * Environment variable name for the custom Experience Edge hostname. + * Used only when rewriting media URLs in layout/editing (rewrite-edge-host). Server-side only. + * @public + */ +export const SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV = 'SITECORE_EXPERIENCE_EDGE_HOSTNAME'; + +/** + * Resolves the Sitecore Edge URL based on configuration and environment. + * + * Priority order: + * 1. Explicit `edgeUrl` parameter (if provided and not empty) + * 2. `SITECORE_EDGE_PLATFORM_HOSTNAME` environment variable + * 3. Default Edge Platform URL (`https://edge-platform.sitecorecloud.io`) + * + * The hostname env var can be provided as: + * - Full URL: `https://my-custom-edge.example.com` + * - Hostname only: `my-custom-edge.example.com` (will be prefixed with `https://`) + * @param {string} [edgeUrl] - Optional explicit Edge URL to use (takes precedence if provided) + * @returns {string} The resolved Edge Platform base URL (normalized, no trailing slash) + * @public + * @example + * resolveEdgeUrl() // => 'https://my-tenant.edge.example.com' + * @example + * resolveEdgeUrl('https://custom.edge.com') // => 'https://custom.edge.com' + * @example + * resolveEdgeUrl() // => 'https://edge-platform.sitecorecloud.io' + */ +export function resolveEdgeUrl(edgeUrl?: string): string { + // Use explicit edgeUrl if provided and not empty + const explicit = normalizeEnvValue(edgeUrl); + if (explicit) { + return normalizeUrl(explicit); + } + + // Check for custom hostname env var + const hostnameEnvVar = normalizeEnvValue(process.env[SITECORE_EDGE_PLATFORM_HOSTNAME_ENV]); + if (hostnameEnvVar) { + return normalizeHostnameToUrl(hostnameEnvVar); + } + + return SITECORE_EDGE_PLATFORM_URL_DEFAULT; +} + +/** + * Resolves the Experience Edge URL for media URL rewriting. + * Use only when rewriting media URLs in layout/editing (e.g. rewriteEdgeHostInResponse). + * Priority: SITECORE_EXPERIENCE_EDGE_HOSTNAME env, then default (edge.sitecorecloud.io). + * Server-side only. + * @returns {string} The Experience Edge base URL (no trailing slash) + * @public + */ +export function resolveExperienceEdgeUrl(): string { + const hostnameEnvVar = normalizeEnvValue( + process.env[SITECORE_EXPERIENCE_EDGE_HOSTNAME_ENV] + ); + if (hostnameEnvVar) { + return normalizeHostnameToUrl(hostnameEnvVar); + } + return SITECORE_EXPERIENCE_EDGE_URL_DEFAULT; +} + +/** + * Resolves the Edge URL for static files (e.g. stylesheets) by ignoring the custom hostname. + * Use this when the custom host does not serve static file paths (e.g. /v1/files/...). + * Returns the default Edge Platform URL. + * @returns {string} The Edge Platform base URL for static files (no trailing slash) + * @public + */ +export function resolveEdgeUrlForStaticFiles(): string { + return SITECORE_EDGE_PLATFORM_URL_DEFAULT; +} + +/** + * Normalizes a hostname or URL to a full HTTPS URL without trailing slash. + * @param {string} hostnameOrUrl - A hostname (e.g., 'my.domain.com') or full URL (e.g., 'https://my.domain.com') + * @returns {string} A normalized HTTPS URL + * @internal + */ +function normalizeHostnameToUrl(hostnameOrUrl: string): string { + const trimmed = hostnameOrUrl.trim(); + + // If it already has a protocol, normalize and return + if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) { + return normalizeUrl(trimmed); + } + + // Otherwise, treat as hostname and add https:// + return normalizeUrl(`https://${trimmed}`); +} + diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router/.cursor/rules/sitecore.mdc b/packages/create-content-sdk-app/src/templates/nextjs-app-router/.cursor/rules/sitecore.mdc index 6ac0f59d4f..2d40d55b1c 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs-app-router/.cursor/rules/sitecore.mdc +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router/.cursor/rules/sitecore.mdc @@ -135,7 +135,10 @@ export default defineConfig({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || 'https://edge-platform.sitecorecloud.io', + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME || + 'https://edge-platform.sitecorecloud.io', }, local: { apiKey: process.env.SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router/.env.remote.example b/packages/create-content-sdk-app/src/templates/nextjs-app-router/.env.remote.example index 9a6ac1643a..646f8c0b71 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs-app-router/.env.remote.example +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router/.env.remote.example @@ -19,6 +19,12 @@ SITECORE_EDGE_CONTEXT_ID= # Will be used as a fallback if separate SITECORE_EDGE_CONTEXT_ID value is not provided NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID= +# Optional: custom Sitecore Edge Platform hostname (hostname or full URL). +NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME= + +# Optional: custom Experience Edge hostname for media URL rewriting (e.g. staging). +SITECORE_EXPERIENCE_EDGE_HOSTNAME= + # An optional Sitecore Personalize scope identifier. # This can be used to isolate personalization data when multiple XM Cloud Environments share a Personalize tenant. # This should match the PAGES_PERSONALIZE_SCOPE environment variable for your connected XM Cloud Environment. diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router/.windsurfrules b/packages/create-content-sdk-app/src/templates/nextjs-app-router/.windsurfrules index cb44a1ae2c..615491a7aa 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs-app-router/.windsurfrules +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router/.windsurfrules @@ -196,7 +196,10 @@ export default defineConfig({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || 'https://edge-platform.sitecorecloud.io', + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME || + 'https://edge-platform.sitecorecloud.io', }, local: { apiKey: process.env.SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router/CLAUDE.md b/packages/create-content-sdk-app/src/templates/nextjs-app-router/CLAUDE.md index 7f88bd0606..1297e5e6b6 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs-app-router/CLAUDE.md +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router/CLAUDE.md @@ -186,7 +186,10 @@ export default defineConfig({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || 'https://edge-platform.sitecorecloud.io', + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME || + 'https://edge-platform.sitecorecloud.io', }, local: { apiKey: process.env.SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router/copilot-instructions.md b/packages/create-content-sdk-app/src/templates/nextjs-app-router/copilot-instructions.md index 2edbe642af..26d1855438 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs-app-router/copilot-instructions.md +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router/copilot-instructions.md @@ -186,7 +186,10 @@ export default defineConfig({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || 'https://edge-platform.sitecorecloud.io', + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME || + 'https://edge-platform.sitecorecloud.io', }, local: { apiKey: process.env.SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router/sitecore.config.ts.example b/packages/create-content-sdk-app/src/templates/nextjs-app-router/sitecore.config.ts.example index 41503a7902..befae0d08a 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs-app-router/sitecore.config.ts.example +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router/sitecore.config.ts.example @@ -12,7 +12,9 @@ export default defineConfig({ process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || process.env.NEXT_PUBLIC_SITECORE_EDGE_URL, + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME, }, local: { apiKey: process.env.NEXT_PUBLIC_SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs/.cursor/rules/sitecore.mdc b/packages/create-content-sdk-app/src/templates/nextjs/.cursor/rules/sitecore.mdc index c9c0d54454..6164102642 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs/.cursor/rules/sitecore.mdc +++ b/packages/create-content-sdk-app/src/templates/nextjs/.cursor/rules/sitecore.mdc @@ -89,7 +89,10 @@ export default defineConfig({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || 'https://edge-platform.sitecorecloud.io', + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME || + 'https://edge-platform.sitecorecloud.io', }, local: { apiKey: process.env.SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs/.env.remote.example b/packages/create-content-sdk-app/src/templates/nextjs/.env.remote.example index 9a6ac1643a..646f8c0b71 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs/.env.remote.example +++ b/packages/create-content-sdk-app/src/templates/nextjs/.env.remote.example @@ -19,6 +19,12 @@ SITECORE_EDGE_CONTEXT_ID= # Will be used as a fallback if separate SITECORE_EDGE_CONTEXT_ID value is not provided NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID= +# Optional: custom Sitecore Edge Platform hostname (hostname or full URL). +NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME= + +# Optional: custom Experience Edge hostname for media URL rewriting (e.g. staging). +SITECORE_EXPERIENCE_EDGE_HOSTNAME= + # An optional Sitecore Personalize scope identifier. # This can be used to isolate personalization data when multiple XM Cloud Environments share a Personalize tenant. # This should match the PAGES_PERSONALIZE_SCOPE environment variable for your connected XM Cloud Environment. diff --git a/packages/create-content-sdk-app/src/templates/nextjs/.windsurfrules b/packages/create-content-sdk-app/src/templates/nextjs/.windsurfrules index 3e4c95c045..faab8d6bfe 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs/.windsurfrules +++ b/packages/create-content-sdk-app/src/templates/nextjs/.windsurfrules @@ -136,7 +136,10 @@ export default defineConfig({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || 'https://edge-platform.sitecorecloud.io', + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME || + 'https://edge-platform.sitecorecloud.io', }, local: { apiKey: process.env.SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs/CLAUDE.md b/packages/create-content-sdk-app/src/templates/nextjs/CLAUDE.md index 4ed39e6362..f0d6611300 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs/CLAUDE.md +++ b/packages/create-content-sdk-app/src/templates/nextjs/CLAUDE.md @@ -126,7 +126,10 @@ export default defineConfig({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || 'https://edge-platform.sitecorecloud.io', + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME || + 'https://edge-platform.sitecorecloud.io', }, local: { apiKey: process.env.SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs/copilot-instructions.md b/packages/create-content-sdk-app/src/templates/nextjs/copilot-instructions.md index 898f38fdc3..789794f152 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs/copilot-instructions.md +++ b/packages/create-content-sdk-app/src/templates/nextjs/copilot-instructions.md @@ -126,7 +126,10 @@ export default defineConfig({ edge: { contextId: process.env.SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || 'https://edge-platform.sitecorecloud.io', + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME || + 'https://edge-platform.sitecorecloud.io', }, local: { apiKey: process.env.SITECORE_API_KEY || '', diff --git a/packages/create-content-sdk-app/src/templates/nextjs/sitecore.config.ts.example b/packages/create-content-sdk-app/src/templates/nextjs/sitecore.config.ts.example index 41503a7902..befae0d08a 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs/sitecore.config.ts.example +++ b/packages/create-content-sdk-app/src/templates/nextjs/sitecore.config.ts.example @@ -12,7 +12,9 @@ export default defineConfig({ process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID || '', clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: process.env.SITECORE_EDGE_URL || process.env.NEXT_PUBLIC_SITECORE_EDGE_URL, + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME, }, local: { apiKey: process.env.NEXT_PUBLIC_SITECORE_API_KEY || '', diff --git a/packages/nextjs/src/config/define-config.test.ts b/packages/nextjs/src/config/define-config.test.ts index 0f052f12e0..288b55c930 100644 --- a/packages/nextjs/src/config/define-config.test.ts +++ b/packages/nextjs/src/config/define-config.test.ts @@ -140,10 +140,10 @@ describe('defineConfig', () => { describe('config.api.edge.edgeUrl', () => { describe('environment variable is not set', () => { - it('should default to undefined', () => { + it('should default to Edge Platform URL', () => { defineConfigModule.defineConfig(defaultConfig()); const resultConfig = defineConfigCoreStub.getCalls()[0].args[0]; - expect(resultConfig.api?.edge?.edgeUrl).to.be.undefined; + expect(resultConfig.api?.edge?.edgeUrl).to.equal('https://edge-platform.sitecorecloud.io'); }); it('should use the value from the config', () => { @@ -157,11 +157,11 @@ describe('defineConfig', () => { }); describe('environment variable is set', () => { before(() => { - process.env.NEXT_PUBLIC_SITECORE_EDGE_URL = 'next-public-sitecore-edgeUrl'; + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME = 'next-public-sitecore-edgeUrl'; }); after(() => { - delete process.env.NEXT_PUBLIC_SITECORE_EDGE_URL; + delete process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME; }); it('should use the value from the config if present', () => { diff --git a/packages/nextjs/src/config/define-config.ts b/packages/nextjs/src/config/define-config.ts index 62f4b967e1..e585174846 100644 --- a/packages/nextjs/src/config/define-config.ts +++ b/packages/nextjs/src/config/define-config.ts @@ -3,6 +3,10 @@ import { defineConfig as defineConfigCore, SitecoreConfigInput as SitecoreConfigInputCore, } from '@sitecore-content-sdk/content/config'; +import { resolveEdgeUrl } from '@sitecore-content-sdk/core/tools'; + +/** Next.js env var for Edge hostname; exposed to the browser so client code can use it. */ +const NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME_ENV = 'NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME'; /** * Provides default NextJs initial values from env variables for SitecoreConfig @@ -19,7 +23,10 @@ export const getNextFallbackConfig = (config?: SitecoreConfigInput): SitecoreCon contextId: config?.api?.edge?.contextId || '', clientContextId: config?.api?.edge?.clientContextId || process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, - edgeUrl: config?.api?.edge?.edgeUrl || process.env.NEXT_PUBLIC_SITECORE_EDGE_URL, + edgeUrl: resolveEdgeUrl( + config?.api?.edge?.edgeUrl ?? + process.env[NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME_ENV] + ), }, local: { ...config?.api?.local, diff --git a/packages/react/src/components/SitecoreProvider.tsx b/packages/react/src/components/SitecoreProvider.tsx index 83008a0c05..cb29bb12aa 100644 --- a/packages/react/src/components/SitecoreProvider.tsx +++ b/packages/react/src/components/SitecoreProvider.tsx @@ -3,7 +3,6 @@ import React from 'react'; import fastDeepEqual from 'fast-deep-equal/es6/react'; import { Page } from '@sitecore-content-sdk/content/client'; import { SitecoreConfig } from '@sitecore-content-sdk/content/config'; -import { constants } from '@sitecore-content-sdk/core'; import { ComponentMap } from './sharedTypes'; import { ImportMapImport } from './DesignLibrary/models'; @@ -76,25 +75,10 @@ export class SitecoreProvider extends React.Component< constructor(props: SitecoreProviderProps) { super(props); - // If any Edge ID is present but no edgeUrl, apply the default - let api = props.api; - if ( - (props.api?.edge?.contextId || props.api?.edge?.clientContextId) && - !props.api?.edge?.edgeUrl - ) { - api = { - ...props.api, - edge: { - ...props.api.edge, - edgeUrl: constants.SITECORE_EDGE_URL_DEFAULT, - }, - }; - } - this.state = { page: props.page, setPage: this.setPage, - api, + api: props.api, }; } diff --git a/packages/search/api/content-sdk-search.api.md b/packages/search/api/content-sdk-search.api.md index cbe51a9814..b1e696f2eb 100644 --- a/packages/search/api/content-sdk-search.api.md +++ b/packages/search/api/content-sdk-search.api.md @@ -54,7 +54,7 @@ export type SortSetting = { // Warnings were encountered during analysis: // // src/models.ts:8:3 - (ae-forgotten-export) The symbol "PrimitiveType" needs to be exported by the entry point index.d.ts -// src/search-service.ts:10:3 - (ae-forgotten-export) The symbol "PathsToStringProps" needs to be exported by the entry point index.d.ts +// src/search-service.ts:11:3 - (ae-forgotten-export) The symbol "PathsToStringProps" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/search/src/search-service.test.ts b/packages/search/src/search-service.test.ts index 00a9427cac..97eea4b43d 100644 --- a/packages/search/src/search-service.test.ts +++ b/packages/search/src/search-service.test.ts @@ -13,7 +13,7 @@ describe('SearchService', () => { }); it('should send a request with the keyphrase', async () => { - nock(constants.SITECORE_EDGE_URL_DEFAULT, { + nock(constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, }, @@ -50,7 +50,7 @@ describe('SearchService', () => { }); it('should send a request with empty keyphrase', async () => { - nock(constants.SITECORE_EDGE_URL_DEFAULT, { + nock(constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, }, @@ -128,7 +128,7 @@ describe('SearchService', () => { it('should send a request with custom limit', async () => { const limit = 20; - nock(constants.SITECORE_EDGE_URL_DEFAULT, { + nock(constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, }, @@ -168,7 +168,7 @@ describe('SearchService', () => { it('should sent a request with custom offset', async () => { const offset = 50; - nock(constants.SITECORE_EDGE_URL_DEFAULT, { + nock(constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, }, @@ -208,7 +208,7 @@ describe('SearchService', () => { it('should send a request with custom sort', async () => { const sort: SortSetting = { name: 'event', order: 'asc' }; - nock(constants.SITECORE_EDGE_URL_DEFAULT, { + nock(constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, }, @@ -251,7 +251,7 @@ describe('SearchService', () => { { name: 'title', order: 'desc' }, ]; - nock(constants.SITECORE_EDGE_URL_DEFAULT, { + nock(constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, }, @@ -289,7 +289,7 @@ describe('SearchService', () => { }); it('should return a default response when no results are found', async () => { - nock(constants.SITECORE_EDGE_URL_DEFAULT, { + nock(constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, }, @@ -323,7 +323,7 @@ describe('SearchService', () => { }); it('should throw an error if the request fails', async () => { - nock(constants.SITECORE_EDGE_URL_DEFAULT, { + nock(constants.SITECORE_EDGE_PLATFORM_URL_DEFAULT, { reqheaders: { 'x-sitecore-contextid': contextId, }, diff --git a/packages/search/src/search-service.ts b/packages/search/src/search-service.ts index f0425e50cd..3b28bf1658 100644 --- a/packages/search/src/search-service.ts +++ b/packages/search/src/search-service.ts @@ -1,4 +1,5 @@ -import { NativeDataFetcher, constants } from '@sitecore-content-sdk/core'; +import { NativeDataFetcher } from '@sitecore-content-sdk/core'; +import { resolveEdgeUrl } from '@sitecore-content-sdk/core/tools'; import { SearchDocument, PathsToStringProps } from './models'; import { debug } from './debug'; @@ -100,7 +101,7 @@ export class SearchService { private fetcher: NativeDataFetcher; constructor(private config: SearchServiceConfig) { - this.config.edgeUrl = this.config.edgeUrl || constants.SITECORE_EDGE_URL_DEFAULT; + this.config.edgeUrl = this.config.edgeUrl ?? resolveEdgeUrl(); this.fetcher = new NativeDataFetcher({ debugger: debug,