diff --git a/CHANGELOG.md b/CHANGELOG.md index f45e2ab813..8dc140c9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Our versioning strategy is as follows: * `[nextjs/template]` `[sitecore-jss-nextjs]` On-demand ISR [#1674](https://github.com/Sitecore/jss/pull/1674)) * `[sitecore-jss]` `[templates/nextjs-xmcloud]` Load the content styles for the RichText component ([#1670](https://github.com/Sitecore/jss/pull/1670))([#1683](https://github.com/Sitecore/jss/pull/1683)) ([#1684](https://github.com/Sitecore/jss/pull/1684)) ([#1693](https://github.com/Sitecore/jss/pull/1693)) * `[templates/react]` `[sitecore-jss-react]` Replace package 'deep-equal' with 'fast-deep-equal'. No functionality change only performance improvement ([#1719](https://github.com/Sitecore/jss/pull/1719)) ([#1665](https://github.com/Sitecore/jss/pull/1665)) +* `[templates/nextjs-xmcloud]` `[sitecore-jss]` `[sitecore-jss-nextjs]` `[sitecore-jss-react]` Add support for loading appropriate stylesheets whenever a theme is applied to BYOC and SXA components by introducing new function getComponentLibraryStylesheetLinks, which replaces getFEAASLibraryStylesheetLinks (which has been marked as deprecated) ([#1722](https://github.com/Sitecore/jss/pull/1722)) ### 🐛 Bug Fixes diff --git a/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/feaas-themes.ts b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/component-themes.ts similarity index 52% rename from packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/feaas-themes.ts rename to packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/component-themes.ts index 8f90e1a72b..ecc5456ff7 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/feaas-themes.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/component-themes.ts @@ -1,15 +1,15 @@ import { SitecorePageProps } from 'lib/page-props'; -import { getFEAASLibraryStylesheetLinks } from '@sitecore-jss/sitecore-jss-nextjs'; +import { getComponentLibraryStylesheetLinks } from '@sitecore-jss/sitecore-jss-nextjs'; import { Plugin } from '..'; import config from 'temp/config'; -class FEeaSThemesPlugin implements Plugin { +class ComponentThemesPlugin implements Plugin { order = 2; async exec(props: SitecorePageProps) { - // Collect FEAAS themes + // Collect FEAAS, BYOC, SXA component themes props.headLinks.push( - ...getFEAASLibraryStylesheetLinks( + ...getComponentLibraryStylesheetLinks( props.layoutData, config.sitecoreEdgeContextId, config.sitecoreEdgeUrl @@ -19,4 +19,4 @@ class FEeaSThemesPlugin implements Plugin { } } -export const feaasThemesPlugin = new FEeaSThemesPlugin(); +export const componentThemesPlugin = new ComponentThemesPlugin(); diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index 2ab768dd26..8ecd6c04fb 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -188,6 +188,7 @@ export { BYOCComponent, BYOCComponentProps, getFEAASLibraryStylesheetLinks, + getComponentLibraryStylesheetLinks, File, FileField, RichTextField, diff --git a/packages/sitecore-jss-react/src/index.ts b/packages/sitecore-jss-react/src/index.ts index 7ac24039cf..f48049b761 100644 --- a/packages/sitecore-jss-react/src/index.ts +++ b/packages/sitecore-jss-react/src/index.ts @@ -11,6 +11,7 @@ export { } from '@sitecore-jss/sitecore-jss/utils'; export { getContentStylesheetLink, + getComponentLibraryStylesheetLinks, LayoutService, LayoutServiceData, LayoutServicePageState, diff --git a/packages/sitecore-jss/src/feaas/themes.ts b/packages/sitecore-jss/src/feaas/themes.ts index 37440d10bf..fb0262e9a1 100644 --- a/packages/sitecore-jss/src/feaas/themes.ts +++ b/packages/sitecore-jss/src/feaas/themes.ts @@ -20,6 +20,7 @@ const FEAAS_LIBRARY_ID_REGEX = /-library--([^\s]+)/; * @param {string} sitecoreEdgeContextId Sitecore Edge Context ID * @param {string} [sitecoreEdgeUrl] Sitecore Edge Platform URL. Default is https://edge-platform.sitecorecloud.io * @returns {HTMLLink[]} library stylesheet links + * @deprecated use getComponentLibraryStylesheetLinks instead; getFEAASLibraryStylesheetLinks will be removed in v22.0 */ export function getFEAASLibraryStylesheetLinks( layoutData: LayoutServiceData, diff --git a/packages/sitecore-jss/src/layout/index.ts b/packages/sitecore-jss/src/layout/index.ts index 6a22058a27..8262ececf2 100644 --- a/packages/sitecore-jss/src/layout/index.ts +++ b/packages/sitecore-jss/src/layout/index.ts @@ -31,3 +31,5 @@ export { } from './rest-layout-service'; export { GraphQLLayoutService, GraphQLLayoutServiceConfig } from './graphql-layout-service'; + +export { getComponentLibraryStylesheetLinks } from './themes'; diff --git a/packages/sitecore-jss/src/layout/themes.test.ts b/packages/sitecore-jss/src/layout/themes.test.ts new file mode 100644 index 0000000000..0e315cda18 --- /dev/null +++ b/packages/sitecore-jss/src/layout/themes.test.ts @@ -0,0 +1,478 @@ +import { expect } from 'chai'; +import { getComponentLibraryStylesheetLinks, getStylesheetUrl } from './themes'; +import { SITECORE_EDGE_URL_DEFAULT } from '../constants'; +import { ComponentRendering, HtmlElementRendering } from '.'; + +describe('themes', () => { + const sitecoreEdgeContextId = 'test'; + + describe('getComponentLibraryStylesheetLinks', () => { + const setBasicLayoutData = (component: ComponentRendering | HtmlElementRendering) => { + return { + sitecore: { + context: {}, + route: { + name: 'home', + placeholders: { + main: [component], + }, + }, + }, + }; + }; + + it('should return empty array route data is not provided', () => { + expect( + getComponentLibraryStylesheetLinks( + { + sitecore: { + context: {}, + route: null, + }, + }, + sitecoreEdgeContextId + ) + ).to.deep.equal([]); + }); + + it('should return links using CSSStyles field', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'test', + fields: { + CSSStyles: { + value: '-library--foo', + }, + LibraryId: { + value: 'bar', + }, + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should return links using Styles field', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'test', + fields: { + Styles: { + value: '-library--foo', + }, + LibraryId: { + value: 'bar', + }, + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should return links using LibraryId field', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'test', + fields: { + LibraryId: { + value: 'bar', + }, + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('bar', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should return links using CSSStyles param', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'styled', + params: { + CSSStyles: '-library--foo', + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should return links using Styles param', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'styled', + params: { + Styles: '-library--foo', + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should return links using LibraryId param', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'styled', + params: { + LibraryId: 'bar', + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('bar', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should return prefer params over fields', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'styled', + params: { + CSSStyles: '-library--foo', + }, + fields: { + CSSStyles: { + value: '-library--not-foo', + }, + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'styled', + params: { + LibraryId: 'bar', + }, + fields: { + LibraryId: { + value: 'not-bar', + }, + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('bar', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should read LibraryId from class when matching param or field is not found', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'styled', + params: { + NotCSSStyles: '-library--not-foo', + NotStyles: '-library--not-foo', + NotLibraryId: 'not-foo', + }, + fields: { + NotCSSStyles: { + value: '-library--not-foo', + }, + NotStyles: { + value: '-library--not-foo', + }, + NotLibraryId: { + value: 'not-foo', + }, + }, + attributes: { + class: '-library--foo', + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('foo', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should return links using non-prod edge url', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'test', + fields: { + LibraryId: { + value: 'bar', + }, + }, + }), + sitecoreEdgeContextId, + 'https://edge-platform-dev.sitecorecloud.io' + ) + ).to.deep.equal([ + { + href: getStylesheetUrl( + 'bar', + sitecoreEdgeContextId, + 'https://edge-platform-dev.sitecorecloud.io' + ), + rel: 'stylesheet', + }, + ]); + }); + + it('should return empty links array when required fields are not provided', () => { + expect( + getComponentLibraryStylesheetLinks( + { + sitecore: { + context: {}, + route: { + name: 'home', + fields: {}, + placeholders: {}, + }, + }, + }, + sitecoreEdgeContextId + ) + ).to.deep.equal([]); + }); + + it('should return empty links array when required params are not provided', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + componentName: 'styled', + params: {}, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([]); + }); + + it('should traverse nested nodes and return only unique links', () => { + expect( + getComponentLibraryStylesheetLinks( + { + sitecore: { + context: {}, + route: { + name: 'home', + fields: { + CSSStyles: { + value: '-library--foo', + }, + LibraryId: { + value: 'bar', + }, + }, + placeholders: { + x: [ + { + componentName: 'x1-component', + fields: { + LibraryId: { + value: 'foo', + }, + }, + placeholders: { + x1: [ + { + componentName: 'x11-component', + fields: { + CSSStyles: { + value: '-library--x11', + }, + }, + }, + { + componentName: 'x12-component', + fields: { + CSSStyles: { + value: '-library--x12', + }, + LibraryId: { + value: 'x12-id', + }, + }, + }, + ], + x2: [ + { + componentName: 'x21-component', + fields: { + LibraryId: { + value: 'x21', + }, + }, + }, + ], + }, + }, + ], + y: [ + { + componentName: 'y1-component', + fields: { + LibraryId: { + value: 'y1', + }, + }, + }, + { + componentName: 'y2-component', + fields: { + CSSStyles: { + value: 'custom-style', + }, + LibraryId: { + value: 'y2', + }, + }, + }, + ], + z: [ + { + componentName: 'z1-component', + fields: { + CSSStyles: { + value: '-library--z1', + }, + }, + placeholders: { + z1: [ + { + componentName: 'z11-component', + fields: { + CSSStyles: { + value: '-library--z11', + }, + }, + }, + ], + z2: [ + { + componentName: 'z21-component', + fields: { + LibraryId: { + value: 'z21', + }, + }, + }, + ], + }, + }, + ], + zx: [ + { + componentName: 'zx1-component', + fields: { + Styles: { + value: 'foo', + }, + }, + placeholders: { + zx1: [ + { + componentName: 'zx11-component', + fields: { + LibraryId: { + value: 'zx11', + }, + }, + }, + ], + zx2: [ + { + componentName: 'zx21-component', + params: { + Styles: '-library--zx21', + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + sitecoreEdgeContextId + ) + ).to.deep.equal( + ['foo', 'x11', 'x12', 'x21', 'y1', 'y2', 'z1', 'z11', 'z21', 'zx11', 'zx21'].map((id) => ({ + href: getStylesheetUrl(id, sitecoreEdgeContextId), + rel: 'stylesheet', + })) + ); + }); + + it('should return links using class attribute', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + name: 'foo-component', + contents: null, + attributes: { + class: '-library--bar', + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([ + { href: getStylesheetUrl('bar', sitecoreEdgeContextId), rel: 'stylesheet' }, + ]); + }); + + it('should not return id when class does not match pattern', () => { + expect( + getComponentLibraryStylesheetLinks( + setBasicLayoutData({ + name: 'foo-component', + contents: null, + attributes: { + class: 'bar', + }, + }), + sitecoreEdgeContextId + ) + ).to.deep.equal([]); + }); + }); + + 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}` + ); + }); + + it('should use non-prod edge url', () => { + const nonProdUrl = 'https://edge-platform-pre-production.sitecorecloud.io'; + expect(getStylesheetUrl('foo', sitecoreEdgeContextId, nonProdUrl)).to.equal( + `${nonProdUrl}/v1/files/components/styles/foo.css?sitecoreContextId=${sitecoreEdgeContextId}` + ); + }); + }); +}); diff --git a/packages/sitecore-jss/src/layout/themes.ts b/packages/sitecore-jss/src/layout/themes.ts new file mode 100644 index 0000000000..324daa4b25 --- /dev/null +++ b/packages/sitecore-jss/src/layout/themes.ts @@ -0,0 +1,104 @@ +import { + ComponentRendering, + HtmlElementRendering, + LayoutServiceData, + RouteData, + getFieldValue, +} from '.'; +import { HTMLLink } from '../models'; +import { SITECORE_EDGE_URL_DEFAULT } from '../constants'; + +/** + * Pattern for library ids + * @example -library--foo + */ +const STYLES_LIBRARY_ID_REGEX = /-library--([^\s]+)/; + +/** + * Walks through rendering tree and returns list of links of all FEAAS, BYOC or SXA Component 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 + * @returns {HTMLLink[]} library stylesheet links + */ +export function getComponentLibraryStylesheetLinks( + layoutData: LayoutServiceData, + sitecoreEdgeContextId: string, + sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT +): HTMLLink[] { + const ids = new Set(); + + if (!layoutData.sitecore.route) return []; + + traverseComponent(layoutData.sitecore.route, ids); + + return [...ids].map((id) => ({ + href: getStylesheetUrl(id, sitecoreEdgeContextId, sitecoreEdgeUrl), + rel: 'stylesheet', + })); +} + +export const getStylesheetUrl = ( + id: string, + sitecoreEdgeContextId: string, + sitecoreEdgeUrl = SITECORE_EDGE_URL_DEFAULT +) => { + return `${sitecoreEdgeUrl}/v1/files/components/styles/${id}.css?sitecoreContextId=${sitecoreEdgeContextId}`; +}; + +/** + * Traverse placeholder and components to add library ids + * @param {Array} components + * @param {Set} ids library ids + */ +const traversePlaceholder = ( + components: Array, + ids: Set +) => { + components.map((component) => { + const rendering = component as ComponentRendering; + + return traverseComponent(rendering, ids); + }); +}; + +/** + * Traverse component and children to add library ids + * @param {RouteData | ComponentRendering | HtmlElementRendering} component component data + * @param {Set} ids library ids + */ +const traverseComponent = ( + component: RouteData | ComponentRendering | HtmlElementRendering, + ids: Set +) => { + let libraryId: string | undefined = undefined; + if ('params' in component && component.params) { + // LibraryID in css class name takes precedence over LibraryId attribute + libraryId = + component.params.CSSStyles?.match(STYLES_LIBRARY_ID_REGEX)?.[1] || + component.params.Styles?.match(STYLES_LIBRARY_ID_REGEX)?.[1] || + component.params.LibraryId || + undefined; + } + // if params are empty we try to fall back to data source or attributes + if (!libraryId && 'fields' in component && component.fields) { + libraryId = + getFieldValue(component.fields, 'CSSStyles', '').match(STYLES_LIBRARY_ID_REGEX)?.[1] || + getFieldValue(component.fields, 'Styles', '').match(STYLES_LIBRARY_ID_REGEX)?.[1] || + getFieldValue(component.fields, 'LibraryId', '') || + undefined; + } + // HTMLRendering its class attribute + if (!libraryId && 'attributes' in component && typeof component.attributes.class === 'string') { + libraryId = component.attributes.class.match(STYLES_LIBRARY_ID_REGEX)?.[1]; + } + if (libraryId) { + ids.add(libraryId); + } + + const placeholders = (component as ComponentRendering).placeholders || {}; + + Object.keys(placeholders).forEach((placeholder) => { + traversePlaceholder(placeholders[placeholder], ids); + }); +};