From 4832b9c041e4f8450e2f3b720b31e443cc7f1040 Mon Sep 17 00:00:00 2001 From: illiakovalenko Date: Mon, 19 Dec 2022 16:09:29 +0200 Subject: [PATCH 01/14] [Next.js][Multi-site] Create plugin approach for extract-path --- .../lib/extract-path/plugins/personalize.ts | 14 ++++++++ .../lib/page-props-factory/extract-path.ts | 23 ------------ .../nextjs/scripts/generate-plugins.ts | 5 +++ .../nextjs/src/lib/extract-path/index.ts | 36 +++++++++++++++++++ .../lib/page-props-factory/extract-path.ts | 19 ---------- .../page-props-factory/plugins/normal-mode.ts | 4 +-- 6 files changed, 57 insertions(+), 44 deletions(-) create mode 100644 packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts delete mode 100644 packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/extract-path.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs/src/lib/extract-path/index.ts delete mode 100644 packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/extract-path.ts diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts new file mode 100644 index 0000000000..a0de57af76 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts @@ -0,0 +1,14 @@ +import { normalizePersonalizedRewrite } from '@sitecore-jss/sitecore-jss-nextjs'; +import { Plugin } from '..'; + +class PersonalizePlugin implements Plugin { + /** + * Extract normalized Sitecore item path from query + * @param {string} path + */ + exec(path: string) { + return normalizePersonalizedRewrite(path); + } +} + +export const personalizePlugin = new PersonalizePlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/extract-path.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/extract-path.ts deleted file mode 100644 index c4baea2862..0000000000 --- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/extract-path.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ParsedUrlQuery } from 'querystring'; -import { normalizePersonalizedRewrite } from '@sitecore-jss/sitecore-jss-nextjs'; - -/** - * Extract normalized Sitecore item path from query - * @param {ParsedUrlQuery | undefined} params - */ -export function extractPath(params: ParsedUrlQuery | undefined): string { - if (params === undefined) { - return '/'; - } - let path = Array.isArray(params.path) ? params.path.join('/') : params.path ?? '/'; - - // Ensure leading '/' - if (!path.startsWith('/')) { - path = '/' + path; - } - - // Ensure personalized rewrite data is removed - path = normalizePersonalizedRewrite(path); - - return path; -} diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-plugins.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-plugins.ts index 4fa646dc86..d0764e60b4 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-plugins.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-plugins.ts @@ -51,6 +51,11 @@ const pluginDefinitions = [ rootPath: 'src/lib/next-config/plugins', moduleType: ModuleType.CJS, }, + { + listPath: 'src/temp/extract-path-plugins.ts', + rootPath: 'src/lib/extract-path/plugins', + moduleType: ModuleType.ESM, + }, ]; run(pluginDefinitions); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/extract-path/index.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/extract-path/index.ts new file mode 100644 index 0000000000..d73b6476e4 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/extract-path/index.ts @@ -0,0 +1,36 @@ +import { ParsedUrlQuery } from 'querystring'; +import * as plugins from 'temp/extract-path-plugins'; + +export interface Plugin { + /** + * A function which will be called during path extraction + */ + exec(path: string): string; +} + +export class PathExtractor { + /** + * Allow plugins to extract normalized path + * @param {ParsedUrlQuery} [params] + */ + public extract(params: ParsedUrlQuery | undefined): string { + if (params === undefined) { + return '/'; + } + let path = Array.isArray(params.path) ? params.path.join('/') : params.path ?? '/'; + + // Ensure leading '/' + if (!path.startsWith('/')) { + path = '/' + path; + } + + const extractedPath = (Object.values(plugins) as Plugin[]).reduce( + (resultPath, plugin) => plugin.exec(resultPath), + path + ); + + return extractedPath; + } +} + +export const pathExtractor = new PathExtractor(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/extract-path.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/extract-path.ts deleted file mode 100644 index fe35d77bb9..0000000000 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/extract-path.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ParsedUrlQuery } from 'querystring'; - -/** - * Extract normalized Sitecore item path from query - * @param {ParsedUrlQuery | undefined} params - */ -export function extractPath(params: ParsedUrlQuery | undefined): string { - if (params === undefined) { - return '/'; - } - let path = Array.isArray(params.path) ? params.path.join('/') : params.path ?? '/'; - - // Ensure leading '/' - if (!path.startsWith('/')) { - path = '/' + path; - } - - return path; -} diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/normal-mode.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/normal-mode.ts index c7457db52a..056e8d68e6 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/normal-mode.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/normal-mode.ts @@ -5,7 +5,7 @@ import { layoutServiceFactory } from 'lib/layout-service-factory'; import { SitecorePageProps } from 'lib/page-props'; import config from 'temp/config'; import { Plugin, isServerSidePropsContext } from '..'; -import { extractPath } from '../extract-path'; +import { pathExtractor } from '../../extract-path'; class NormalModePlugin implements Plugin { private dictionaryService: DictionaryService; @@ -25,7 +25,7 @@ class NormalModePlugin implements Plugin { * Normal mode */ // Get normalized Sitecore item path - const path = extractPath(context.params); + const path = pathExtractor.extract(context.params); // Use context locale if Next.js i18n is configured, otherwise use default language props.locale = context.locale ?? config.defaultLanguage; From d4986bf7675fb001f84d6a7610fd147e4e3ae582 Mon Sep 17 00:00:00 2001 From: illiakovalenko Date: Mon, 19 Dec 2022 19:26:17 +0200 Subject: [PATCH 02/14] Minor changes to JSDoc --- .../src/lib/extract-path/plugins/personalize.ts | 4 ---- .../src/templates/nextjs/src/lib/extract-path/index.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts index a0de57af76..93158212c6 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts @@ -2,10 +2,6 @@ import { normalizePersonalizedRewrite } from '@sitecore-jss/sitecore-jss-nextjs' import { Plugin } from '..'; class PersonalizePlugin implements Plugin { - /** - * Extract normalized Sitecore item path from query - * @param {string} path - */ exec(path: string) { return normalizePersonalizedRewrite(path); } diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/extract-path/index.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/extract-path/index.ts index d73b6476e4..baaa1ca4b4 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/extract-path/index.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/extract-path/index.ts @@ -10,7 +10,7 @@ export interface Plugin { export class PathExtractor { /** - * Allow plugins to extract normalized path + * Extract normalized Sitecore item path * @param {ParsedUrlQuery} [params] */ public extract(params: ParsedUrlQuery | undefined): string { From 9d42e01c9f9e4f52fc21f123cd1f59f1924f065d Mon Sep 17 00:00:00 2001 From: Illia Kovalenko <23364749+illiakovalenko@users.noreply.github.com> Date: Tue, 3 Jan 2023 18:49:34 +0200 Subject: [PATCH 03/14] [Next.js][Multi-site] Dynamic site resolver (#1271) * [Next.js][Multi-site] Dynamic site resolver * add changes * Add unit tests, parse pattern * etc * simplify reg exp --- packages/sitecore-jss/src/site/index.ts | 2 + .../src/site/site-resolver.test.ts | 162 ++++++++++++++++++ .../sitecore-jss/src/site/site-resolver.ts | 56 ++++++ 3 files changed, 220 insertions(+) create mode 100644 packages/sitecore-jss/src/site/site-resolver.test.ts create mode 100644 packages/sitecore-jss/src/site/site-resolver.ts diff --git a/packages/sitecore-jss/src/site/index.ts b/packages/sitecore-jss/src/site/index.ts index de04de1d68..4213601d5e 100644 --- a/packages/sitecore-jss/src/site/index.ts +++ b/packages/sitecore-jss/src/site/index.ts @@ -24,3 +24,5 @@ export { GraphQLErrorPagesService, GraphQLErrorPagesServiceConfig, } from './graphql-error-pages-service'; + +export { SiteResolver, HostInfo } from './site-resolver'; diff --git a/packages/sitecore-jss/src/site/site-resolver.test.ts b/packages/sitecore-jss/src/site/site-resolver.test.ts new file mode 100644 index 0000000000..28777e4269 --- /dev/null +++ b/packages/sitecore-jss/src/site/site-resolver.test.ts @@ -0,0 +1,162 @@ +import { expect } from 'chai'; +import { HostInfo, SiteResolver } from './site-resolver'; + +describe.only('SiteResolver', () => { + const hostInfo: HostInfo = { + hostName: 'foo.com', + }; + + const hostInfoWithLanguage: HostInfo = { + ...hostInfo, + language: 'en', + }; + + describe('resolve', () => { + describe('fallback site name', () => { + it('should return default when sites info is empty', () => { + const siteName = SiteResolver.resolve(hostInfo, []); + + expect(siteName).to.equal('website'); + }); + + it('should return default when there is no appropriate site info', () => { + const siteName = SiteResolver.resolve(hostInfo, [ + { hostName: 'bar.com', language: '', name: 'bar' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + + expect(siteName).to.equal('website'); + }); + + it('should return custom when sites info is empty', () => { + const siteName = SiteResolver.resolve(hostInfo, [], 'sample'); + + expect(siteName).to.equal('sample'); + }); + + it('should return custom when there is no appropriate site info', () => { + const siteName = SiteResolver.resolve( + hostInfo, + [ + { hostName: 'bar.com', language: '', name: 'bar' }, + { hostName: 'var.com', language: '', name: 'var' }, + ], + 'sample' + ); + + expect(siteName).to.equal('sample'); + }); + }); + + it('should return site name when only hostname is provided', () => { + const siteName = SiteResolver.resolve(hostInfo, [ + { hostName: 'bar.net', language: '', name: 'bar' }, + { hostName: 'foo.com', language: '', name: 'foo' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + + expect(siteName).to.equal('foo'); + }); + + it('should return site name when hostname includes wildcard', () => { + const siteName = SiteResolver.resolve(hostInfo, [ + { hostName: 'var.com', language: 'da-DK', name: 'var' }, + { hostName: 'bar.net', language: 'en', name: 'bar' }, + { hostName: '*.com', language: '', name: 'foo' }, + { hostName: 'foo.com', language: 'en', name: 'foo-en' }, + ]); + + expect(siteName).to.equal('foo'); + }); + + it('should return site name when wildcard is provided', () => { + const siteName = SiteResolver.resolve(hostInfo, [ + { hostName: 'bar.net', language: '', name: 'bar' }, + { hostName: '*', language: '', name: 'wildcard' }, + { hostName: 'foo.com', language: '', name: 'foo' }, + ]); + + expect(siteName).to.equal('wildcard'); + }); + + it('should return site name when language is provided', () => { + const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ + { hostName: 'foo.com', language: 'ca', name: 'foo-ca' }, + { hostName: 'var.com', language: 'da-DK', name: 'var' }, + { hostName: 'bar.net', language: 'en', name: 'bar' }, + { hostName: 'foo.com', language: 'en', name: 'foo-en' }, + ]); + + expect(siteName).to.equal('foo-en'); + }); + + it('should return site name when language is omit', () => { + const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ + { hostName: 'var.com', language: 'da-DK', name: 'var' }, + { hostName: 'bar.net', language: 'en', name: 'bar' }, + { hostName: 'foo.com', language: '', name: 'foo' }, + { hostName: 'foo.com', language: 'en', name: 'foo-en' }, + ]); + + expect(siteName).to.equal('foo'); + }); + + describe('should return site name when multi-value hostnames are provided', () => { + it('hostnames include wildcard characters', () => { + expect( + SiteResolver.resolve({ hostName: 'test.foo.bar.com' }, [ + { hostName: '*.bat.com|foo.bar.com', language: '', name: 'bar' }, + { hostName: 'test.com|*.foo.*.com|foo.com', language: '', name: 'foo' }, + ]) + ).to.equal('foo'); + + expect( + SiteResolver.resolve({ hostName: 'xfoo.bar.com.en' }, [ + { hostName: 'foo.bar.com', language: '', name: 'bar' }, + { hostName: 'test.com|*foo.*.com*|foo.com', language: '', name: 'foo' }, + ]) + ).to.equal('foo'); + }); + + it('hostname contains whitespaces', () => { + const siteName = SiteResolver.resolve(hostInfo, [ + { hostName: 'bar.net', language: '', name: 'bar' }, + { hostName: 'test.com; foo.net | foo.com', language: '', name: 'foo' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + + expect(siteName).to.equal('foo'); + }); + + it('comma delimiter is used', () => { + const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ + { hostName: 'bar.net', language: '', name: 'bar' }, + { hostName: 'test.com,foo.net,foo.com', language: 'en', name: 'foo' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + + expect(siteName).to.equal('foo'); + }); + + it('semicolon delimiter is used', () => { + const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ + { hostName: 'bar.net', language: '', name: 'bar' }, + { hostName: 'test.com;foo.net;foo.com', language: 'en', name: 'foo' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + + expect(siteName).to.equal('foo'); + }); + + it('pipe delimiter is used', () => { + const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ + { hostName: 'bar.net', language: '', name: 'bar' }, + { hostName: 'test.com|foo.net|foo.com', language: 'en', name: 'foo' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + + expect(siteName).to.equal('foo'); + }); + }); + }); +}); diff --git a/packages/sitecore-jss/src/site/site-resolver.ts b/packages/sitecore-jss/src/site/site-resolver.ts new file mode 100644 index 0000000000..231a2e89d6 --- /dev/null +++ b/packages/sitecore-jss/src/site/site-resolver.ts @@ -0,0 +1,56 @@ +import { SiteInfo } from './graphql-siteinfo-service'; + +/** + * Information about the current host + */ +export type HostInfo = { + hostName: string; + language?: string; +}; + +// Delimiters for multi-value hostnames +const DELIMITERS = /\||,|;/g; + +/** + * Determines site name based on the provided host information + */ +export class SiteResolver { + /** + * Resolve siteName by host information + * @param {HostInfo} hostInfo information about current host + * @param {SiteInfo[]} sitesInfo list of available sites + * @param {string} [fallbackSiteName] siteName to be returned in case siteName is not found + * @returns {string} siteName resolved site name + */ + static resolve = ( + hostInfo: HostInfo, + sitesInfo: SiteInfo[], + fallbackSiteName = 'website' + ): string => { + const siteInfo = sitesInfo.find((info) => { + const hostnames = info.hostName.replace(/\s/g, '').split(DELIMITERS); + + const languageMatches = + info.language === '' || !hostInfo.language || hostInfo.language === info.language; + + return hostnames.some( + (hostname) => + languageMatches && + (hostInfo.hostName === hostname || + SiteResolver.matchesPattern(hostInfo.hostName, hostname)) + ); + }); + + return siteInfo?.name || fallbackSiteName; + }; + + private static matchesPattern(hostname: string, pattern: string): boolean { + // dots should be treated as chars + // stars should be treated as wildcards + const regExpPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); + + const regExp = new RegExp(`^${regExpPattern}$`, 'g'); + + return !!hostname.match(regExp); + } +} From a75a453f6d60a317404eeab771fa5b9a03e09e0a Mon Sep 17 00:00:00 2001 From: Illia Kovalenko <23364749+illiakovalenko@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:07:39 +0200 Subject: [PATCH 04/14] [Next.js][Multi-site] Multi-site path utils (#1275) * [Next.js][Multi-site] Multi-site path utils * Add return type to `getSiteRewriteData` Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> --- packages/sitecore-jss/src/site/index.ts | 1 + packages/sitecore-jss/src/site/utils.test.ts | 88 ++++++++++++++++++++ packages/sitecore-jss/src/site/utils.ts | 49 +++++++++++ 3 files changed, 138 insertions(+) create mode 100644 packages/sitecore-jss/src/site/utils.test.ts create mode 100644 packages/sitecore-jss/src/site/utils.ts diff --git a/packages/sitecore-jss/src/site/index.ts b/packages/sitecore-jss/src/site/index.ts index 4213601d5e..69b3821e0a 100644 --- a/packages/sitecore-jss/src/site/index.ts +++ b/packages/sitecore-jss/src/site/index.ts @@ -25,4 +25,5 @@ export { GraphQLErrorPagesServiceConfig, } from './graphql-error-pages-service'; +export { getSiteRewrite, getSiteRewriteData, normalizeSiteRewrite, SiteRewriteData } from './utils'; export { SiteResolver, HostInfo } from './site-resolver'; diff --git a/packages/sitecore-jss/src/site/utils.test.ts b/packages/sitecore-jss/src/site/utils.test.ts new file mode 100644 index 0000000000..83e09d891f --- /dev/null +++ b/packages/sitecore-jss/src/site/utils.test.ts @@ -0,0 +1,88 @@ +import { expect } from 'chai'; +import { getSiteRewrite, getSiteRewriteData, normalizeSiteRewrite, SITE_PREFIX } from './utils'; + +describe('utils', () => { + describe('getSiteRewrite', () => { + const data = { + siteName: 'jss', + }; + + it('should return a string', () => { + expect(getSiteRewrite('/pathname', data)).to.be.a('string'); + }); + + it('should return the path with the site name when pathname starts with "/"', () => { + const pathname = '/some/path'; + const result = getSiteRewrite(pathname, data); + expect(result).to.equal(`/${SITE_PREFIX}${data.siteName}/some/path`); + }); + + it('should return the path with the site name when pathname not starts with "/"', () => { + const pathname = 'some/path'; + const result = getSiteRewrite(pathname, data); + expect(result).to.equal(`/${SITE_PREFIX}${data.siteName}/some/path`); + }); + + it('should return the root path with the site name', () => { + const pathname = '/'; + const result = getSiteRewrite(pathname, data); + expect(result).to.equal(`/${SITE_PREFIX}${data.siteName}/`); + }); + }); + + describe('getSiteRewriteData', () => { + const defaultSiteName = 'foo'; + + it('should return a MultiSiteRewriteData object', () => { + expect(getSiteRewriteData('/some/path', defaultSiteName)).to.be.an('object'); + }); + + it('should return the multisite data from the rewrite path', () => { + const pathname = `/some/path/${SITE_PREFIX}bar/`; + const result = getSiteRewriteData(pathname, defaultSiteName); + expect(result.siteName).to.equal('bar'); + }); + + it('should return the default site name when pathname does not contain site name', () => { + const pathname = '/some/path'; + const result = getSiteRewriteData(pathname, defaultSiteName); + expect(result.siteName).to.equal(defaultSiteName); + }); + + it('should return empty site name when pathname is missing site name', () => { + const pathname = `/some/path/${SITE_PREFIX}/`; + const result = getSiteRewriteData(pathname, defaultSiteName); + expect(result.siteName).to.equal(defaultSiteName); + }); + }); + + describe('normalizeSiteRewrite', () => { + it('should return a string', () => { + expect(normalizeSiteRewrite('/some/path')).to.be.a('string'); + }); + + it('should return the pathname when it does not contain site prefix', () => { + const pathname = '/some/path'; + const result = normalizeSiteRewrite(pathname); + expect(result).to.equal(pathname); + }); + + it('should return the pathname without the site name', () => { + const pathname = `/${SITE_PREFIX}foo/some/path`; + const result = normalizeSiteRewrite(pathname); + expect(result).to.equal('/some/path'); + }); + + it('should return the root pathname without the site name', () => { + const pathname = `/${SITE_PREFIX}foo/`; + const result = normalizeSiteRewrite(pathname); + expect(result).to.equal('/'); + }); + + it('should return the root pathname without the site name when pathname not ends with "/"', () => { + const pathname = `/${SITE_PREFIX}foo`; + const result = normalizeSiteRewrite(pathname); + expect(result).to.equal('/'); + }); + }); +}); diff --git a/packages/sitecore-jss/src/site/utils.ts b/packages/sitecore-jss/src/site/utils.ts new file mode 100644 index 0000000000..f769e616cf --- /dev/null +++ b/packages/sitecore-jss/src/site/utils.ts @@ -0,0 +1,49 @@ +export const SITE_PREFIX = '_site_'; + +export type SiteRewriteData = { + siteName: string; +}; + +/** + * Get a site rewrite path for given pathname + * @param {string} pathname the pathname + * @param {SiteRewriteData} data the site data to include in the rewrite + * @returns {string} the rewrite path + */ +export function getSiteRewrite(pathname: string, data: SiteRewriteData): string { + const path = pathname.startsWith('/') ? pathname : '/' + pathname; + + return `/${SITE_PREFIX}${data.siteName}${path}`; +} + +/** + * Get site data from the rewrite path + * @param {string} pathname the pathname + * @param {string} defaultSiteName the default site name + * @returns {SiteRewriteData} the site data from the rewrite + */ +export function getSiteRewriteData(pathname: string, defaultSiteName: string): SiteRewriteData { + const data: SiteRewriteData = { + siteName: defaultSiteName, + }; + + const path = pathname.endsWith('/') ? pathname : pathname + '/'; + const result = path.match(`${SITE_PREFIX}(.*?)\\/`); + + if (result && result[1] !== '') { + data.siteName = result[1]; + } + + return data; +} + +/** + * Normalize a site rewrite path (remove site data) + * @param {string} pathname the pathname + * @returns {string} the pathname with site data removed + */ +export function normalizeSiteRewrite(pathname: string): string { + const result = pathname.match(`${SITE_PREFIX}.*?(?:\\/|$)`); + + return result === null ? pathname : pathname.replace(result[0], ''); +} From 96bd727e3614f4d6d795468871822ccc00b1d29f Mon Sep 17 00:00:00 2001 From: Adam Brauer <400763+ambrauer@users.noreply.github.com> Date: Tue, 3 Jan 2023 13:41:07 -0600 Subject: [PATCH 05/14] [Next.js][Multi-site] Fetch site info during build (#1276) * GraphQL site info service (#1227) * GraphQL site info service * some tweaks * more tweaks: debug, query * query fix for xm cloud * move template id to contstants * [Next.js][Multi-site] Dynamic site resolver (#1224) * [Next.js][Multi-site] Dynamic site resolver * Use SiteInfo from graphql-siteinfo-service Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> * Plugin approach for JSS config generation. Added multisite plugin to set the "sites" prop. * Standardized comments in config plugins. Misc fixes. * Fixed tests and relocated headlessSiteGroupingTemplate constant (since this query is temp anyway) * Moved defaultLanguage of 'en' to defaultConfig * Simplify config plugin order. Delete multisite-sample.ts Co-authored-by: Artem Alexeyenko Co-authored-by: Illia Kovalenko <23364749+illiakovalenko@users.noreply.github.com> --- .../nextjs-multisite/multisite-sample.ts | 1 - .../scripts/config/plugins/multisite.ts | 43 ++++++ .../scripts/config/plugins/disconnected.ts | 34 +++++ .../src/templates/nextjs/package.json | 1 + .../src/templates/nextjs/scripts/bootstrap.ts | 36 ++--- .../templates/nextjs/scripts/config/index.ts | 39 +++++ .../nextjs/scripts/config/plugins/computed.ts | 18 +++ .../scripts/config/plugins/package-json.ts | 21 +++ .../scripts/config/plugins/scjssconfig.ts | 28 ++++ .../nextjs/scripts/generate-config.ts | 103 +++++--------- .../nextjs/scripts/generate-plugins.ts | 8 +- .../templates/nextjs/scripts/temp/.gitignore | 2 + .../templates/nextjs/tsconfig.scripts.json | 5 + packages/sitecore-jss-nextjs/src/index.ts | 3 + packages/sitecore-jss/src/debug.ts | 1 + .../src/site/graphql-siteinfo-service.test.ts | 118 ++++++++++++++++ .../src/site/graphql-siteinfo-service.ts | 133 ++++++++++++++++++ packages/sitecore-jss/src/site/index.ts | 6 + 18 files changed, 505 insertions(+), 95 deletions(-) delete mode 100644 packages/create-sitecore-jss/src/templates/nextjs-multisite/multisite-sample.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs-multisite/scripts/config/plugins/multisite.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs-styleguide/scripts/config/plugins/disconnected.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs/scripts/config/index.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/computed.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/package-json.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/scjssconfig.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs/scripts/temp/.gitignore create mode 100644 packages/sitecore-jss/src/site/graphql-siteinfo-service.test.ts create mode 100644 packages/sitecore-jss/src/site/graphql-siteinfo-service.ts diff --git a/packages/create-sitecore-jss/src/templates/nextjs-multisite/multisite-sample.ts b/packages/create-sitecore-jss/src/templates/nextjs-multisite/multisite-sample.ts deleted file mode 100644 index e02abfc9b0..0000000000 --- a/packages/create-sitecore-jss/src/templates/nextjs-multisite/multisite-sample.ts +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/create-sitecore-jss/src/templates/nextjs-multisite/scripts/config/plugins/multisite.ts b/packages/create-sitecore-jss/src/templates/nextjs-multisite/scripts/config/plugins/multisite.ts new file mode 100644 index 0000000000..c9baf1bcf7 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-multisite/scripts/config/plugins/multisite.ts @@ -0,0 +1,43 @@ +import 'dotenv/config'; +import chalk from 'chalk'; +import { ConfigPlugin, JssConfig } from '..'; +import { GraphQLSiteInfoService, SiteInfo } from '@sitecore-jss/sitecore-jss-nextjs'; + +/** + * This plugin will set the "sites" config prop. + * By default this will attempt to fetch site information directly from Sitecore (using the GraphQLSiteInfoService). + * You could easily modify this to fetch from another source such as a static JSON file instead. + */ +class MultisitePlugin implements ConfigPlugin { + order = 3; + + async exec(config: JssConfig) { + let sites: SiteInfo[] = []; + const endpoint = process.env.GRAPH_QL_ENDPOINT || config.graphQLEndpoint; + const apiKey = process.env.SITECORE_API_KEY || config.sitecoreApiKey; + + if (!endpoint || !apiKey) { + console.warn( + chalk.yellow('Skipping site information fetch (missing GraphQL connection details)') + ); + } else { + console.log(`Fetching site information from ${endpoint}`); + try { + const siteInfoService = new GraphQLSiteInfoService({ + endpoint, + apiKey, + }); + sites = await siteInfoService.fetchSiteInfo(); + } catch (error) { + console.error(chalk.red('Error fetching site information')); + console.error(error); + } + } + + return Object.assign({}, config, { + sites: JSON.stringify(sites), + }); + } +} + +export const multisitePlugin = new MultisitePlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs-styleguide/scripts/config/plugins/disconnected.ts b/packages/create-sitecore-jss/src/templates/nextjs-styleguide/scripts/config/plugins/disconnected.ts new file mode 100644 index 0000000000..bd5c185d59 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-styleguide/scripts/config/plugins/disconnected.ts @@ -0,0 +1,34 @@ +import 'dotenv/config'; +import chalk from 'chalk'; +import { constants } from '@sitecore-jss/sitecore-jss-nextjs'; +import { ConfigPlugin, JssConfig } from '..'; + +/** + * This plugin will override the "sitecoreApiHost" config prop + * for disconnected mode (using disconnected server). + */ +class DisconnectedPlugin implements ConfigPlugin { + order = 3; + + async exec(config: JssConfig) { + const disconnected = process.env.JSS_MODE === constants.JSS_MODE.DISCONNECTED; + + if (!disconnected) return config; + + if (process.env.FETCH_WITH === constants.FETCH_WITH.GRAPHQL) { + throw new Error( + chalk.red( + 'GraphQL requests to Dictionary and Layout services are not supported in disconnected mode.' + ) + ); + } + + const port = process.env.PORT || 3000; + + return Object.assign({}, config, { + sitecoreApiHost: `http://localhost:${port}`, + }); + } +} + +export const disconnectedPlugin = new DisconnectedPlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/package.json b/packages/create-sitecore-jss/src/templates/nextjs/package.json index 4cd2a11f67..1330a5af2f 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/package.json +++ b/packages/create-sitecore-jss/src/templates/nextjs/package.json @@ -67,6 +67,7 @@ "npm-run-all": "~4.1.5", "prettier": "^2.1.2", "ts-node": "^9.0.0", + "tsconfig-paths": "^4.1.1", "typescript": "~4.3.5", "yaml-loader": "^0.6.0" }, diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/bootstrap.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/bootstrap.ts index 3c9085a10a..b8ada06806 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/scripts/bootstrap.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/bootstrap.ts @@ -1,41 +1,21 @@ -import 'dotenv/config'; -import { generateConfig } from './generate-config'; -import { constants } from '@sitecore-jss/sitecore-jss-nextjs'; -import chalk from 'chalk'; /* BOOTSTRAPPING The bootstrap process runs before build, and generates JS that needs to be - included into the build - specifically, the component name to component mapping, - and the global config module. + included into the build - specifically, plugins, the global config module, + and the component name to component mapping. */ -const disconnected = process.env.JSS_MODE === constants.JSS_MODE.DISCONNECTED; /* - CONFIG GENERATION - Generates the /src/temp/config.js file which contains runtime configuration - that the app can import and use. + PLUGINS GENERATION */ -const port = process.env.PORT || 3000; -const configOverride: { [key: string]: string } = {}; -if (disconnected) { - if (process.env.FETCH_WITH === constants.FETCH_WITH.GRAPHQL) { - throw new Error( - chalk.red( - 'GraphQL requests to Dictionary and Layout services are not supported in disconnected mode.' - ) - ); - } - configOverride.sitecoreApiHost = `http://localhost:${port}`; -} - -generateConfig(configOverride); +import './generate-plugins'; /* - COMPONENT FACTORY GENERATION + CONFIG GENERATION */ -import './generate-component-factory'; +import './generate-config'; /* - PLUGINS GENERATION + COMPONENT FACTORY GENERATION */ -import './generate-plugins'; +import './generate-component-factory'; diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/index.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/index.ts new file mode 100644 index 0000000000..425474d0eb --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/index.ts @@ -0,0 +1,39 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const plugins = require('scripts/temp/config-plugins'); + +/** + * JSS configuration object + */ +export interface JssConfig extends Record { + sitecoreApiKey?: string; + sitecoreApiHost?: string; + jssAppName?: string; + graphQLEndpointPath?: string; + defaultLanguage?: string; + graphQLEndpoint?: string; +} + +export interface ConfigPlugin { + /** + * Detect order when the plugin should be called, e.g. 0 - will be called first (can be a plugin which data is required for other plugins) + */ + order: number; + /** + * A function which will be called during config generation + * @param {JssConfig} config Current (accumulated) config + */ + exec(config: JssConfig): Promise; +} + +export class JssConfigFactory { + public async create(defaultConfig: JssConfig = {}): Promise { + return (Object.values(plugins) as ConfigPlugin[]) + .sort((p1, p2) => p1.order - p2.order) + .reduce( + (promise, plugin) => promise.then((config) => plugin.exec(config)), + Promise.resolve(defaultConfig) + ); + } +} + +export const jssConfigFactory = new JssConfigFactory(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/computed.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/computed.ts new file mode 100644 index 0000000000..dc487ce88b --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/computed.ts @@ -0,0 +1,18 @@ +import { ConfigPlugin, JssConfig } from '..'; + +/** + * This plugin will set computed config props. + * The "graphQLEndpoint" is an example of making a _computed_ config setting + * based on other config settings. + */ +class ComputedPlugin implements ConfigPlugin { + order = 2; + + async exec(config: JssConfig) { + return Object.assign({}, config, { + graphQLEndpoint: `${config.sitecoreApiHost}${config.graphQLEndpointPath}`, + }); + } +} + +export const computedPlugin = new ComputedPlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/package-json.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/package-json.ts new file mode 100644 index 0000000000..3fffb6b1ec --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/package-json.ts @@ -0,0 +1,21 @@ +import { ConfigPlugin, JssConfig } from '..'; +import packageConfig from 'package.json'; + +/** + * This plugin will set config props based on package.json. + */ +class PackageJsonPlugin implements ConfigPlugin { + order = 1; + + async exec(config: JssConfig) { + if (!packageConfig.config) return config; + + return Object.assign({}, config, { + jssAppName: packageConfig.config.appName, + graphQLEndpointPath: packageConfig.config.graphQLEndpointPath, + defaultLanguage: packageConfig.config.language, + }); + } +} + +export const packageJsonPlugin = new PackageJsonPlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/scjssconfig.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/scjssconfig.ts new file mode 100644 index 0000000000..4967161658 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/config/plugins/scjssconfig.ts @@ -0,0 +1,28 @@ +import { ConfigPlugin, JssConfig } from '..'; + +/** + * This plugin will set config props based on scjssconfig.json. + * scjssconfig.json may not exist if you've never run `jss setup` (development) + * or are depending on environment variables instead (production). + */ +class ScJssConfigPlugin implements ConfigPlugin { + order = 1; + + async exec(config: JssConfig) { + let scJssConfig; + try { + scJssConfig = require('scjssconfig.json'); + } catch (e) { + return config; + } + + if (!scJssConfig) return config; + + return Object.assign({}, config, { + sitecoreApiKey: scJssConfig.sitecore?.apiKey, + sitecoreApiHost: scJssConfig.sitecore?.layoutServiceHost, + }); + } +} + +export const scjssconfigPlugin = new ScJssConfigPlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-config.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-config.ts index 30ec5ea4ba..db3a565686 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-config.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-config.ts @@ -1,53 +1,55 @@ import fs from 'fs'; import path from 'path'; import { constantCase } from 'constant-case'; -import packageConfig from '../package.json'; +import { JssConfig, jssConfigFactory } from './config'; -/* eslint-disable no-console */ +/* + CONFIG GENERATION + Generates the /src/temp/config.js file which contains runtime configuration + that the app can import and use. +*/ -/** - * Generate config - * The object returned from this function will be made available by importing src/temp/config.js. - * This is executed prior to the build running, so it's a way to inject environment or build config-specific - * settings as variables into the JSS app. - * NOTE! Any configs returned here will be written into the client-side JS bundle. DO NOT PUT SECRETS HERE. - * @param {object} configOverrides Keys in this object will override any equivalent global config keys. - */ -export function generateConfig(configOverrides?: { [key: string]: string }): void { - const defaultConfig = { - sitecoreApiKey: 'no-api-key-set', - sitecoreApiHost: '', - jssAppName: 'Unknown', - }; +const defaultConfig: JssConfig = { + sitecoreApiKey: 'no-api-key-set', + sitecoreApiHost: '', + jssAppName: 'Unknown', + graphQLEndpointPath: '', + defaultLanguage: 'en', +}; - // require + combine config sources - const scjssConfig = transformScJssConfig(); - const packageJson = transformPackageConfig(); +generateConfig(defaultConfig); - // Object.assign merges the objects in order, so config overrides are performed as: - // default config <-- scjssconfig.json <-- package.json <-- configOverrides - // Optional: add any other dynamic config source (e.g. environment-specific config files). - const config = Object.assign(defaultConfig, scjssConfig, packageJson, configOverrides); - - // The GraphQL endpoint is an example of making a _computed_ config setting - // based on other config settings. - const computedConfig: { [key: string]: string } = {}; - computedConfig.graphQLEndpoint = '`${config.sitecoreApiHost}${config.graphQLEndpointPath}`'; +/** + * Generates the JSS config based on config plugins (under ./config/plugins) + * and then writes the config to disk. + * @param {JssConfig} defaultConfig Default configuration. + */ +function generateConfig(defaultConfig: JssConfig): void { + jssConfigFactory + .create(defaultConfig) + .then((config) => { + writeConfig(config); + }) + .catch((e) => { + console.error('Error generating config'); + console.error(e); + process.exit(1); + }); +} +/** + * Writes the config object to disk with support for environment variables. + * @param {JssConfig} config JSS configuration to write. + */ +function writeConfig(config: JssConfig): void { let configText = `/* eslint-disable */ // Do not edit this file, it is auto-generated at build time! // See scripts/bootstrap.ts to modify the generation of this file. const config = {};\n`; - // Set base configuration values, allowing override with environment variables + // Set configuration values, allowing override with environment variables Object.keys(config).forEach((prop) => { - configText += `config.${prop} = process.env.${constantCase(prop)} || "${config[prop]}",\n`; - }); - // Set computed values, allowing override with environment variables - Object.keys(computedConfig).forEach((prop) => { - configText += `config.${prop} = process.env.${constantCase(prop)} || ${ - computedConfig[prop] - };\n`; + configText += `config.${prop} = process.env.${constantCase(prop)} || '${config[prop]}',\n`; }); configText += `module.exports = config;`; @@ -55,32 +57,3 @@ const config = {};\n`; console.log(`Writing runtime config to ${configPath}`); fs.writeFileSync(configPath, configText, { encoding: 'utf8' }); } - -function transformScJssConfig() { - // scjssconfig.json may not exist if you've never run `jss setup` (development) - // or are depending on environment variables instead (production). - let config; - try { - // eslint-disable-next-line global-require - config = require('../scjssconfig.json'); - } catch (e) { - return {}; - } - - if (!config) return {}; - - return { - sitecoreApiKey: config.sitecore.apiKey, - sitecoreApiHost: config.sitecore.layoutServiceHost, - }; -} - -function transformPackageConfig() { - if (!packageConfig.config) return {}; - - return { - jssAppName: packageConfig.config.appName, - graphQLEndpointPath: packageConfig.config.graphQLEndpointPath, - defaultLanguage: packageConfig.config.language || 'en', - }; -} diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-plugins.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-plugins.ts index d0764e60b4..ca6e16c4b5 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-plugins.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/generate-plugins.ts @@ -31,6 +31,11 @@ interface PluginFile { } const pluginDefinitions = [ + { + listPath: 'scripts/temp/config-plugins.ts', + rootPath: 'scripts/config/plugins', + moduleType: ModuleType.ESM, + }, { listPath: 'src/temp/sitemap-fetcher-plugins.ts', rootPath: 'src/lib/sitemap-fetcher/plugins', @@ -75,7 +80,8 @@ function run(definitions: PluginDefinition[]) { * Modify this function to use a different convention. */ function writePlugins(listPath: string, rootPath: string, moduleType: ModuleType) { - const pluginName = rootPath.split('/')[2]; + const segments = rootPath.split('/'); + const pluginName = segments[segments.length - 2]; const plugins = getPluginList(rootPath, pluginName); let fileContent = ''; diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/temp/.gitignore b/packages/create-sitecore-jss/src/templates/nextjs/scripts/temp/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/temp/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/packages/create-sitecore-jss/src/templates/nextjs/tsconfig.scripts.json b/packages/create-sitecore-jss/src/templates/nextjs/tsconfig.scripts.json index 95d91de5d6..e00a2d17c4 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/tsconfig.scripts.json +++ b/packages/create-sitecore-jss/src/templates/nextjs/tsconfig.scripts.json @@ -1,5 +1,10 @@ { "extends": "./tsconfig.json", + "ts-node": { + "require": [ + "tsconfig-paths/register" + ] + }, "compilerOptions": { "module": "commonjs" } diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index c027bfb619..e2da79a6b8 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -90,6 +90,9 @@ export { GraphQLErrorPagesService, GraphQLErrorPagesServiceConfig, ErrorPages, + SiteInfo, + GraphQLSiteInfoService, + GraphQLSiteInfoServiceConfig, } from '@sitecore-jss/sitecore-jss/site'; export { StaticPath } from './services/graphql-sitemap-service'; diff --git a/packages/sitecore-jss/src/debug.ts b/packages/sitecore-jss/src/debug.ts index af4d5240d8..c62916210a 100644 --- a/packages/sitecore-jss/src/debug.ts +++ b/packages/sitecore-jss/src/debug.ts @@ -33,6 +33,7 @@ export default { dictionary: debug(`${rootNamespace}:dictionary`), editing: debug(`${rootNamespace}:editing`), sitemap: debug(`${rootNamespace}:sitemap`), + multisite: debug(`${rootNamespace}:multisite`), robots: debug(`${rootNamespace}:robots`), redirects: debug(`${rootNamespace}:redirects`), personalize: debug(`${rootNamespace}:personalize`), diff --git a/packages/sitecore-jss/src/site/graphql-siteinfo-service.test.ts b/packages/sitecore-jss/src/site/graphql-siteinfo-service.test.ts new file mode 100644 index 0000000000..69ccb09b83 --- /dev/null +++ b/packages/sitecore-jss/src/site/graphql-siteinfo-service.test.ts @@ -0,0 +1,118 @@ +import { expect } from 'chai'; +import nock from 'nock'; +import { GraphQLSiteInfoService } from './graphql-siteinfo-service'; + +describe('GraphQLSiteInfoService', () => { + const endpoint = 'http://site'; + const apiKey = 'some-api-key'; + const nonEmptyResponse = { + data: { + search: { + results: [ + { + name: 'site 51', + hostName: { + value: 'restricted.gov', + }, + language: { + value: 'en', + }, + }, + { + name: 'public', + hostName: { + value: 'pr.showercurtains.org', + }, + language: { + value: '', + }, + }, + ], + }, + }, + }; + const emptyResponse = { + data: { + search: { + results: [], + }, + }, + }; + + afterEach(() => { + nock.cleanAll(); + }); + + const mockSiteInfoRequest = () => { + nock(endpoint) + .post('/') + .reply(200, nonEmptyResponse); + }; + + it('should return correct result', async () => { + mockSiteInfoRequest(); + const service = new GraphQLSiteInfoService({ apiKey: apiKey, endpoint: endpoint }); + const result = await service.fetchSiteInfo(); + expect(result).to.be.deep.equal([ + { + name: 'site 51', + hostName: 'restricted.gov', + language: 'en', + }, + { + name: 'public', + hostName: 'pr.showercurtains.org', + language: '', + }, + ]); + }); + + it('should return empty array when empty result received', async () => { + nock(endpoint) + .post('/') + .reply(200, emptyResponse); + const service = new GraphQLSiteInfoService({ apiKey: apiKey, endpoint: endpoint }); + const result = await service.fetchSiteInfo(); + expect(result).to.deep.equal([]); + }); + + it('should use caching by default', async () => { + mockSiteInfoRequest(); + const service = new GraphQLSiteInfoService({ apiKey: apiKey, endpoint: endpoint }); + const result = await service.fetchSiteInfo(); + nock.cleanAll(); + nock(endpoint) + .post('/') + .reply(200, emptyResponse); + const resultCached = await service.fetchSiteInfo(); + expect(resultCached).to.deep.equal(result); + }); + + it('should be possible to disable cache', async () => { + mockSiteInfoRequest(); + const service = new GraphQLSiteInfoService({ + apiKey: apiKey, + endpoint: endpoint, + cacheEnabled: false, + }); + const result = await service.fetchSiteInfo(); + expect(result).to.be.deep.equal([ + { + name: 'site 51', + hostName: 'restricted.gov', + language: 'en', + }, + { + name: 'public', + hostName: 'pr.showercurtains.org', + language: '', + }, + ]); + nock.cleanAll(); + nock(endpoint) + .post('/') + .reply(200, emptyResponse); + const resultCached = await service.fetchSiteInfo(); + expect(resultCached).to.deep.equal([]); + }); +}); diff --git a/packages/sitecore-jss/src/site/graphql-siteinfo-service.ts b/packages/sitecore-jss/src/site/graphql-siteinfo-service.ts new file mode 100644 index 0000000000..bc10133030 --- /dev/null +++ b/packages/sitecore-jss/src/site/graphql-siteinfo-service.ts @@ -0,0 +1,133 @@ +import { GraphQLClient, GraphQLRequestClient } from '../graphql'; +import debug from '../debug'; +import { CacheClient, CacheOptions, MemoryCacheClient } from '../cache-client'; + +const headlessSiteGroupingTemplate = 'E46F3AF2-39FA-4866-A157-7017C4B2A40C'; + +const defaultQuery = /* GraphQL */ ` + { + search( + where: { name: "_templates", value: "${headlessSiteGroupingTemplate}", operator: CONTAINS } + ) { + results { + ... on Item { + name + hostName: field(name: "Hostname") { + value + } + language: field(name: "Language") { + value + } + } + } + } +} +`; + +export type SiteInfo = { + /** + * Site name + */ + name: string; + /** + * Site host name. May include multiple values (separated by '|') and wildcards ('*') + */ + hostName: string; + /** + * Site default language + */ + language: string; +}; + +export type GraphQLSiteInfoServiceConfig = CacheOptions & { + /** + * Your Graphql endpoint + */ + endpoint: string; + /** + * The API key to use for authentication + */ + apiKey: string; +}; + +type GraphQLSiteInfoResponse = { + search: { + results: GraphQLSiteInfoResult[]; + }; +}; + +type GraphQLSiteInfoResult = { + name: string; + hostName: { + value: string; + }; + language: { + value: string; + }; +}; + +export class GraphQLSiteInfoService { + private graphQLClient: GraphQLClient; + private cache: CacheClient; + + protected get query(): string { + return defaultQuery; + } + + /** + * Creates an instance of graphQL service to retrieve site configuration list from Sitecore + * @param {GraphQLSiteInfoServiceConfig} config instance + */ + constructor(private config: GraphQLSiteInfoServiceConfig) { + this.graphQLClient = this.getGraphQLClient(); + this.cache = this.getCacheClient(); + } + + async fetchSiteInfo(): Promise { + const cachedResult = this.cache.getCacheValue(this.getCacheKey()); + if (cachedResult) { + return cachedResult; + } + + const response = await this.graphQLClient.request(this.query); + const result = response?.search?.results?.reduce((result, current) => { + result.push({ + name: current.name, + hostName: current.hostName.value, + language: current.language.value, + }); + return result; + }, []); + this.cache.setCacheValue(this.getCacheKey(), result); + return result; + } + + /** + * Gets cache client implementation + * Override this method if custom cache needs to be used + * @returns CacheClient instance + */ + protected getCacheClient(): CacheClient { + return new MemoryCacheClient({ + cacheEnabled: this.config.cacheEnabled ?? true, + cacheTimeout: this.config.cacheTimeout ?? 10, + }); + } + + /** + * Gets a GraphQL client that can make requests to the API. Uses graphql-request as the default + * library for fetching graphql data (@see GraphQLRequestClient). Override this method if you + * want to use something else. + * @returns {GraphQLClient} implementation + */ + protected getGraphQLClient(): GraphQLClient { + return new GraphQLRequestClient(this.config.endpoint, { + apiKey: this.config.apiKey, + debugger: debug.multisite, + }); + } + + private getCacheKey(): string { + return 'siteinfo-service-cache'; + } +} diff --git a/packages/sitecore-jss/src/site/index.ts b/packages/sitecore-jss/src/site/index.ts index 69b3821e0a..da7be554a0 100644 --- a/packages/sitecore-jss/src/site/index.ts +++ b/packages/sitecore-jss/src/site/index.ts @@ -25,5 +25,11 @@ export { GraphQLErrorPagesServiceConfig, } from './graphql-error-pages-service'; +export { + SiteInfo, + GraphQLSiteInfoService, + GraphQLSiteInfoServiceConfig, +} from './graphql-siteinfo-service'; + export { getSiteRewrite, getSiteRewriteData, normalizeSiteRewrite, SiteRewriteData } from './utils'; export { SiteResolver, HostInfo } from './site-resolver'; From 9d960f2dfb536dea41254ad6e4dc7aa4f6ead4df Mon Sep 17 00:00:00 2001 From: Addy Pathania <89087450+sc-addypathania@users.noreply.github.com> Date: Thu, 5 Jan 2023 12:17:36 -0500 Subject: [PATCH 06/14] [Next.js][Multi-site] Site resolution in API routes (#1277) * add site resolution in api routes * re-exported SiteResolver from nextjs package * passed siteInfo to siteResolver * refactored based on changes in resolver function --- .../templates/nextjs-sxa/src/pages/api/robots.ts | 15 ++++++++++++--- .../templates/nextjs-sxa/src/pages/api/sitemap.ts | 14 ++++++++++++-- packages/sitecore-jss-nextjs/src/index.ts | 1 + 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/robots.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/robots.ts index 36531852cd..b926d4cc04 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/robots.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/robots.ts @@ -1,16 +1,25 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import config from 'temp/config'; -import { GraphQLRobotsService } from '@sitecore-jss/sitecore-jss-nextjs'; +import { GraphQLRobotsService, SiteResolver } from '@sitecore-jss/sitecore-jss-nextjs'; -const robotsApi = async (_req: NextApiRequest, res: NextApiResponse): Promise => { +const robotsApi = async (req: NextApiRequest, res: NextApiResponse): Promise => { // Ensure response is text/html res.setHeader('Content-Type', 'text/html;charset=utf-8'); + // Resolve site based on hostname" + const hostName = req.headers['host']?.split(':')[0] || 'localhost'; + + // Site information fetched directly from Sitecore. + const sites = JSON.parse(config.sites); + + // Resolve site name based on host information + const siteName = await SiteResolver.resolve(hostName, sites); + // create robots graphql service const robotsService = new GraphQLRobotsService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, + siteName: siteName || config.jssAppName, }); const robotsResult = await robotsService.fetchRobots(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts index 77330657b4..920c86751e 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts @@ -1,7 +1,7 @@ import { AxiosResponse } from 'axios'; import type { NextApiRequest, NextApiResponse } from 'next'; import config from 'temp/config'; -import { AxiosDataFetcher, GraphQLSitemapXmlService } from '@sitecore-jss/sitecore-jss-nextjs'; +import { AxiosDataFetcher, GraphQLSitemapXmlService, SiteResolver } from '@sitecore-jss/sitecore-jss-nextjs'; const ABSOLUTE_URL_REGEXP = '^(?:[a-z]+:)?//'; @@ -12,11 +12,21 @@ const sitemapApi = async ( const { query: { id }, } = req; + + // Resolve site based on hostname" + const hostName = req.headers['host']?.split(':')[0] || 'localhost'; + + // Sites information fetched directly from Sitecore. + const sites = JSON.parse(config.sites); + + // Resolve site name based on host information + const siteName = await SiteResolver.resolve(hostName, sites); + // create sitemap graphql service const sitemapXmlService = new GraphQLSitemapXmlService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, + siteName: siteName || config.jssAppName, }); // if url has sitemap-{n}.xml type. The id - can be null if it's sitemap.xml request diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index e2da79a6b8..eb3085ed37 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -64,6 +64,7 @@ export { RobotsQueryResult, GraphQLRobotsService, GraphQLRobotsServiceConfig, + SiteResolver, } from '@sitecore-jss/sitecore-jss/site'; export { GraphQLRequestClient } from '@sitecore-jss/sitecore-jss'; From ed32f7553e44ae96d74f3d32b16f4faab0e6c86f Mon Sep 17 00:00:00 2001 From: Adam Brauer <400763+ambrauer@users.noreply.github.com> Date: Mon, 9 Jan 2023 01:32:20 -0600 Subject: [PATCH 07/14] [Next.js][Multi-site] Site resolution in page props factory (#1281) * SiteResolver.resolve updates: removed 'language' from site resolution logic, return SiteInfo instead of site name * Another refactor of SiteResolver. It now must be instantiated with the array of SiteInfo passed. Also introduced a way to resolve by site name ("getByName"). The old "resolve" became "getByHost". * Added "site" prop to page props and resolve for both base nextjs initializer (single site) and nextjs-multisite add-on. * misc lint fixes * Adjustments to API routes based on latest SiteResolver changes * Added comments * Removed generateConfig from fetch-graphql-introspection-data.ts. No longer works after config generation refactor, and not necessary anyway since we provide details on how to generate config in error message anyway --- .../src/lib/extract-path/plugins/multisite.ts | 11 + .../lib/page-props-factory/plugins/site.ts | 29 +++ .../nextjs-multisite/src/lib/site-resolver.ts | 21 ++ .../lib/extract-path/plugins/personalize.ts | 1 + .../page-props-factory/plugins/personalize.ts | 2 +- .../nextjs-styleguide/src/Navigation.tsx | 2 +- .../nextjs-sxa/src/pages/api/robots.ts | 14 +- .../nextjs-sxa/src/pages/api/sitemap.ts | 14 +- .../src/templates/nextjs/next.config.js | 3 +- .../fetch-graphql-introspection-data.ts | 3 - .../src/lib/dictionary-service-factory.ts | 14 +- .../nextjs/src/lib/layout-service-factory.ts | 14 +- .../plugins/component-props.ts | 2 +- .../page-props-factory/plugins/normal-mode.ts | 50 ++-- .../plugins/preview-mode.ts | 2 +- .../lib/page-props-factory/plugins/site.ts | 17 ++ .../templates/nextjs/src/lib/page-props.ts | 2 + .../templates/nextjs/src/lib/site-resolver.ts | 23 ++ .../src/templates/nextjs/src/middleware.ts | 14 +- .../templates/nextjs/src/pages/api/healthz.ts | 2 +- packages/sitecore-jss-nextjs/src/index.ts | 13 +- .../src/site/graphql-siteinfo-service.ts | 4 + packages/sitecore-jss/src/site/index.ts | 2 +- .../src/site/site-resolver.test.ts | 220 ++++++++++-------- .../sitecore-jss/src/site/site-resolver.ts | 68 +++--- 25 files changed, 352 insertions(+), 195 deletions(-) create mode 100644 packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/extract-path/plugins/multisite.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/page-props-factory/plugins/site.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/site-resolver.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/site.ts create mode 100644 packages/create-sitecore-jss/src/templates/nextjs/src/lib/site-resolver.ts diff --git a/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/extract-path/plugins/multisite.ts b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/extract-path/plugins/multisite.ts new file mode 100644 index 0000000000..b4b9d8e463 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/extract-path/plugins/multisite.ts @@ -0,0 +1,11 @@ +import { normalizeSiteRewrite } from '@sitecore-jss/sitecore-jss-nextjs'; +import { Plugin } from '..'; + +class MultisitePlugin implements Plugin { + exec(path: string) { + // Remove site rewrite segment from the path + return normalizeSiteRewrite(path); + } +} + +export const multisitePlugin = new MultisitePlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/page-props-factory/plugins/site.ts b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/page-props-factory/plugins/site.ts new file mode 100644 index 0000000000..a0a9578545 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/page-props-factory/plugins/site.ts @@ -0,0 +1,29 @@ +import { SitecorePageProps } from 'lib/page-props'; +import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; +import { getSiteRewriteData } from '@sitecore-jss/sitecore-jss-nextjs'; +import { Plugin } from '..'; +import { siteResolver } from 'lib/site-resolver'; +import config from 'temp/config'; + +class SitePlugin implements Plugin { + order = 0; + + async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) { + const path = + context.params === undefined + ? '/' + : Array.isArray(context.params.path) + ? context.params.path.join('/') + : context.params.path ?? '/'; + + // Get site name (from path) + const siteData = getSiteRewriteData(path, config.jssAppName); + + // Resolve site by name + props.site = siteResolver.getByName(siteData.siteName); + + return props; + } +} + +export const sitePlugin = new SitePlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/site-resolver.ts b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/site-resolver.ts new file mode 100644 index 0000000000..ec447f4507 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/site-resolver.ts @@ -0,0 +1,21 @@ +import { SiteResolver, SiteInfo } from '@sitecore-jss/sitecore-jss-nextjs'; +import config from 'temp/config'; + +/* + The site resolver stores site information and is used in the app + whenever site lookup is required (e.g. by name in page props factory + or by host in Next.js middleware). +*/ + +// Grab our configured sites +const sites = JSON.parse(config.sites) as SiteInfo[]; + +// Then add fallback site with default values +sites.push({ + name: config.jssAppName, + language: config.defaultLanguage, + hostName: '*', +}); + +/** SiteResolver singleton */ +export const siteResolver = new SiteResolver(sites); diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts index 93158212c6..29d7792963 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/extract-path/plugins/personalize.ts @@ -3,6 +3,7 @@ import { Plugin } from '..'; class PersonalizePlugin implements Plugin { exec(path: string) { + // Remove personalize rewrite segment from the path return normalizePersonalizedRewrite(path); } } diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts index 9673d8f6fe..038fa0d9cc 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts @@ -4,7 +4,7 @@ import { getPersonalizedRewriteData, personalizeLayout } from '@sitecore-jss/sit import { SitecorePageProps } from 'lib/page-props'; class PersonalizePlugin implements Plugin { - order = 2; + order = 3; async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) { const path = diff --git a/packages/create-sitecore-jss/src/templates/nextjs-styleguide/src/Navigation.tsx b/packages/create-sitecore-jss/src/templates/nextjs-styleguide/src/Navigation.tsx index 5d27ca0ff9..4523b0aa1e 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-styleguide/src/Navigation.tsx +++ b/packages/create-sitecore-jss/src/templates/nextjs-styleguide/src/Navigation.tsx @@ -38,4 +38,4 @@ const Navigation = (): JSX.Element => { ); }; -export default Navigation; \ No newline at end of file +export default Navigation; diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/robots.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/robots.ts index b926d4cc04..d7807aad5e 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/robots.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/robots.ts @@ -1,25 +1,21 @@ import type { NextApiRequest, NextApiResponse } from 'next'; +import { GraphQLRobotsService } from '@sitecore-jss/sitecore-jss-nextjs'; +import { siteResolver } from 'lib/site-resolver'; import config from 'temp/config'; -import { GraphQLRobotsService, SiteResolver } from '@sitecore-jss/sitecore-jss-nextjs'; const robotsApi = async (req: NextApiRequest, res: NextApiResponse): Promise => { // Ensure response is text/html res.setHeader('Content-Type', 'text/html;charset=utf-8'); - // Resolve site based on hostname" + // Resolve site based on hostname const hostName = req.headers['host']?.split(':')[0] || 'localhost'; - - // Site information fetched directly from Sitecore. - const sites = JSON.parse(config.sites); - - // Resolve site name based on host information - const siteName = await SiteResolver.resolve(hostName, sites); + const site = siteResolver.getByHost(hostName); // create robots graphql service const robotsService = new GraphQLRobotsService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: siteName || config.jssAppName, + siteName: site.name, }); const robotsResult = await robotsService.fetchRobots(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts index 920c86751e..5cdccd2ae6 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts @@ -1,7 +1,8 @@ import { AxiosResponse } from 'axios'; import type { NextApiRequest, NextApiResponse } from 'next'; +import { AxiosDataFetcher, GraphQLSitemapXmlService } from '@sitecore-jss/sitecore-jss-nextjs'; +import { siteResolver } from 'lib/site-resolver'; import config from 'temp/config'; -import { AxiosDataFetcher, GraphQLSitemapXmlService, SiteResolver } from '@sitecore-jss/sitecore-jss-nextjs'; const ABSOLUTE_URL_REGEXP = '^(?:[a-z]+:)?//'; @@ -13,20 +14,15 @@ const sitemapApi = async ( query: { id }, } = req; - // Resolve site based on hostname" + // Resolve site based on hostname const hostName = req.headers['host']?.split(':')[0] || 'localhost'; - - // Sites information fetched directly from Sitecore. - const sites = JSON.parse(config.sites); - - // Resolve site name based on host information - const siteName = await SiteResolver.resolve(hostName, sites); + const site = siteResolver.getByHost(hostName); // create sitemap graphql service const sitemapXmlService = new GraphQLSitemapXmlService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: siteName || config.jssAppName, + siteName: site.name, }); // if url has sitemap-{n}.xml type. The id - can be null if it's sitemap.xml request diff --git a/packages/create-sitecore-jss/src/templates/nextjs/next.config.js b/packages/create-sitecore-jss/src/templates/nextjs/next.config.js index 73b05d9359..1f04af1ca5 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/next.config.js +++ b/packages/create-sitecore-jss/src/templates/nextjs/next.config.js @@ -1,5 +1,4 @@ const jssConfig = require('./src/temp/config'); -const packageConfig = require('./package.json').config; const { getPublicUrl } = require('@sitecore-jss/sitecore-jss-nextjs'); const plugins = require('./src/temp/next-config-plugins') || {}; @@ -26,7 +25,7 @@ const nextConfig = { locales: ['en'], // This is the locale that will be used when visiting a non-locale // prefixed path e.g. `/styleguide`. - defaultLocale: packageConfig.language, + defaultLocale: jssConfig.defaultLanguage, }, // Enable React Strict Mode diff --git a/packages/create-sitecore-jss/src/templates/nextjs/scripts/fetch-graphql-introspection-data.ts b/packages/create-sitecore-jss/src/templates/nextjs/scripts/fetch-graphql-introspection-data.ts index 99516bd0a9..356d135a93 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/scripts/fetch-graphql-introspection-data.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/scripts/fetch-graphql-introspection-data.ts @@ -1,13 +1,10 @@ import { GraphQLRequestClient } from '@sitecore-jss/sitecore-jss-nextjs'; import fs from 'fs'; import { getIntrospectionQuery } from 'graphql'; -import { generateConfig } from './generate-config'; // This script load graphql introspection data in order to use graphql code generator and generate typescript types // The `jss graphql:update` command should be executed when Sitecore templates related to the site are altered. -generateConfig(); - let jssConfig; try { diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/dictionary-service-factory.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/dictionary-service-factory.ts index 289d6c7cf4..bf78187c9d 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/dictionary-service-factory.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/dictionary-service-factory.ts @@ -6,13 +6,20 @@ import { } from '@sitecore-jss/sitecore-jss-nextjs'; import config from 'temp/config'; +/** + * Factory responsible for creating a DictionaryService instance + */ export class DictionaryServiceFactory { - create(): DictionaryService { + /** + * @param {string} siteName site name + * @returns {DictionaryService} service instance + */ + create(siteName: string): DictionaryService { return process.env.FETCH_WITH === constants.FETCH_WITH.GRAPHQL ? new GraphQLDictionaryService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, + siteName, /* The Dictionary Service needs a root item ID in order to fetch dictionary phrases for the current app. If your Sitecore instance only has 1 JSS App, you can specify the root item ID here; @@ -23,9 +30,10 @@ export class DictionaryServiceFactory { : new RestDictionaryService({ apiHost: config.sitecoreApiHost, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, + siteName, }); } } +/** DictionaryServiceFactory singleton */ export const dictionaryServiceFactory = new DictionaryServiceFactory(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/layout-service-factory.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/layout-service-factory.ts index bf29a61026..3b00c52743 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/layout-service-factory.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/layout-service-factory.ts @@ -6,21 +6,29 @@ import { } from '@sitecore-jss/sitecore-jss-nextjs'; import config from 'temp/config'; +/** + * Factory responsible for creating a LayoutService instance + */ export class LayoutServiceFactory { - create(): LayoutService { + /** + * @param {string} siteName site name + * @returns {LayoutService} service instance + */ + create(siteName: string): LayoutService { return process.env.FETCH_WITH === constants.FETCH_WITH.GRAPHQL ? new GraphQLLayoutService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, + siteName, }) : new RestLayoutService({ apiHost: config.sitecoreApiHost, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, + siteName, configurationName: 'default', }); } } +/** LayoutServiceFactory singleton */ export const layoutServiceFactory = new LayoutServiceFactory(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/component-props.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/component-props.ts index e5b91ec8e7..9a7a1a4bb2 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/component-props.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/component-props.ts @@ -7,7 +7,7 @@ import { Plugin, isServerSidePropsContext } from '..'; class ComponentPropsPlugin implements Plugin { private componentPropsService: ComponentPropsService; - order = 1; + order = 2; constructor() { this.componentPropsService = new ComponentPropsService(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/normal-mode.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/normal-mode.ts index 056e8d68e6..093ddd16fe 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/normal-mode.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/normal-mode.ts @@ -3,35 +3,32 @@ import { DictionaryService, LayoutService } from '@sitecore-jss/sitecore-jss-nex import { dictionaryServiceFactory } from 'lib/dictionary-service-factory'; import { layoutServiceFactory } from 'lib/layout-service-factory'; import { SitecorePageProps } from 'lib/page-props'; -import config from 'temp/config'; +import { pathExtractor } from 'lib/extract-path'; import { Plugin, isServerSidePropsContext } from '..'; -import { pathExtractor } from '../../extract-path'; class NormalModePlugin implements Plugin { - private dictionaryService: DictionaryService; - private layoutService: LayoutService; + private dictionaryServices: Map; + private layoutServices: Map; - order = 0; + order = 1; constructor() { - this.dictionaryService = dictionaryServiceFactory.create(); - this.layoutService = layoutServiceFactory.create(); + this.dictionaryServices = new Map(); + this.layoutServices = new Map(); } async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) { if (context.preview) return props; - /** - * Normal mode - */ // Get normalized Sitecore item path const path = pathExtractor.extract(context.params); - // Use context locale if Next.js i18n is configured, otherwise use default language - props.locale = context.locale ?? config.defaultLanguage; + // Use context locale if Next.js i18n is configured, otherwise use default site language + props.locale = context.locale ?? props.site.language; // Fetch layout data, passing on req/res for SSR - props.layoutData = await this.layoutService.fetchLayoutData( + const layoutService = this.getLayoutService(props.site.name); + props.layoutData = await layoutService.fetchLayoutData( path, props.locale, // eslint-disable-next-line prettier/prettier @@ -48,10 +45,35 @@ class NormalModePlugin implements Plugin { } // Fetch dictionary data - props.dictionary = await this.dictionaryService.fetchDictionaryData(props.locale); + const dictionaryService = this.getDictionaryService(props.site.name); + props.dictionary = await dictionaryService.fetchDictionaryData(props.locale); return props; } + + private getDictionaryService(siteName: string): DictionaryService { + if (this.dictionaryServices.has(siteName)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.dictionaryServices.get(siteName)!; + } + + const dictionaryService = dictionaryServiceFactory.create(siteName); + this.dictionaryServices.set(siteName, dictionaryService); + + return dictionaryService; + } + + private getLayoutService(siteName: string): LayoutService { + if (this.layoutServices.has(siteName)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.layoutServices.get(siteName)!; + } + + const layoutService = layoutServiceFactory.create(siteName); + this.layoutServices.set(siteName, layoutService); + + return layoutService; + } } export const normalModePlugin = new NormalModePlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/preview-mode.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/preview-mode.ts index b19c1d3415..ea01542a2f 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/preview-mode.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/preview-mode.ts @@ -4,7 +4,7 @@ import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; import { Plugin } from '..'; class PreviewModePlugin implements Plugin { - order = 0; + order = 1; async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) { if (!context.preview) return props; diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/site.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/site.ts new file mode 100644 index 0000000000..11d323e816 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props-factory/plugins/site.ts @@ -0,0 +1,17 @@ +import { SitecorePageProps } from 'lib/page-props'; +import { Plugin } from '..'; +import { siteResolver } from 'lib/site-resolver'; +import config from 'temp/config'; + +class SitePlugin implements Plugin { + order = 0; + + async exec(props: SitecorePageProps) { + // Resolve site by name + props.site = siteResolver.getByName(config.jssAppName); + + return props; + } +} + +export const sitePlugin = new SitePlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props.ts index 79c84d1c47..a4a1a30a41 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/page-props.ts @@ -3,12 +3,14 @@ import { DictionaryPhrases, ComponentPropsCollection, LayoutServiceData, + SiteInfo, } from '@sitecore-jss/sitecore-jss-nextjs'; /** * Sitecore page props */ export type SitecorePageProps = { + site: SiteInfo; locale: string; dictionary: DictionaryPhrases; componentProps: ComponentPropsCollection; diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/site-resolver.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/site-resolver.ts new file mode 100644 index 0000000000..52df6f09c3 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/site-resolver.ts @@ -0,0 +1,23 @@ +import { SiteResolver } from '@sitecore-jss/sitecore-jss-nextjs'; +import config from 'temp/config'; + +/* + The site resolver stores site information and is used in the app + whenever site lookup is required (e.g. by name in page props factory + or by host in Next.js middleware). + + By default, the app is single-site (one JSS app per Sitecore site). + However, multi-site is available with the `nextjs-multisite` add-on. +*/ + +// Add our single site +const sites = [ + { + name: config.jssAppName, + language: config.defaultLanguage, + hostName: '*', + }, +]; + +/** SiteResolver singleton */ +export const siteResolver = new SiteResolver(sites); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/middleware.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/middleware.ts index a930f49bfc..11b98f43f9 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/middleware.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/middleware.ts @@ -8,12 +8,12 @@ export default async function (req: NextRequest, ev: NextFetchEvent) { export const config = { /* - * Match all paths except for: - * 1. /api routes - * 2. /_next (Next.js internals) - * 3. /sitecore/api (Sitecore API routes) - * 4. /- (Sitecore media) - * 5. all root files inside /public (e.g. /favicon.ico) - */ + * Match all paths except for: + * 1. /api routes + * 2. /_next (Next.js internals) + * 3. /sitecore/api (Sitecore API routes) + * 4. /- (Sitecore media) + * 5. all root files inside /public (e.g. /favicon.ico) + */ matcher: ['/', '/((?!api/|_next/|sitecore/api/|-/|[\\w-]+\\.\\w+).*)'], }; diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/pages/api/healthz.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/pages/api/healthz.ts index db889f10f8..1279cb6b02 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/pages/api/healthz.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/pages/api/healthz.ts @@ -4,7 +4,7 @@ import { HealthcheckMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/monitor * This Next.js API route is used to handle healthz check request. * By default this is used only by Sitecore XM Cloud (when running as editing host), * but could be used in other deployment scenarios. -*/ + */ // Wire up the HealthcheckMiddleware handler const handler = new HealthcheckMiddleware().getHandler(); diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index eb3085ed37..f2cf108082 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -60,12 +60,6 @@ export { normalizePersonalizedRewrite, CdpHelper, } from '@sitecore-jss/sitecore-jss/personalize'; -export { - RobotsQueryResult, - GraphQLRobotsService, - GraphQLRobotsServiceConfig, - SiteResolver, -} from '@sitecore-jss/sitecore-jss/site'; export { GraphQLRequestClient } from '@sitecore-jss/sitecore-jss'; export { @@ -90,10 +84,17 @@ export { GraphQLSitemapXmlServiceConfig, GraphQLErrorPagesService, GraphQLErrorPagesServiceConfig, + RobotsQueryResult, + GraphQLRobotsService, + GraphQLRobotsServiceConfig, ErrorPages, SiteInfo, + SiteResolver, GraphQLSiteInfoService, GraphQLSiteInfoServiceConfig, + getSiteRewrite, + getSiteRewriteData, + normalizeSiteRewrite, } from '@sitecore-jss/sitecore-jss/site'; export { StaticPath } from './services/graphql-sitemap-service'; diff --git a/packages/sitecore-jss/src/site/graphql-siteinfo-service.ts b/packages/sitecore-jss/src/site/graphql-siteinfo-service.ts index bc10133030..fb53d416c3 100644 --- a/packages/sitecore-jss/src/site/graphql-siteinfo-service.ts +++ b/packages/sitecore-jss/src/site/graphql-siteinfo-service.ts @@ -25,6 +25,10 @@ const defaultQuery = /* GraphQL */ ` `; export type SiteInfo = { + /** + * Additional user-defined properties + */ + [key: string]: unknown; /** * Site name */ diff --git a/packages/sitecore-jss/src/site/index.ts b/packages/sitecore-jss/src/site/index.ts index da7be554a0..917072073c 100644 --- a/packages/sitecore-jss/src/site/index.ts +++ b/packages/sitecore-jss/src/site/index.ts @@ -32,4 +32,4 @@ export { } from './graphql-siteinfo-service'; export { getSiteRewrite, getSiteRewriteData, normalizeSiteRewrite, SiteRewriteData } from './utils'; -export { SiteResolver, HostInfo } from './site-resolver'; +export { SiteResolver } from './site-resolver'; diff --git a/packages/sitecore-jss/src/site/site-resolver.test.ts b/packages/sitecore-jss/src/site/site-resolver.test.ts index 28777e4269..5c2f16cfed 100644 --- a/packages/sitecore-jss/src/site/site-resolver.test.ts +++ b/packages/sitecore-jss/src/site/site-resolver.test.ts @@ -1,162 +1,176 @@ +/* eslint-disable no-unused-expressions */ import { expect } from 'chai'; -import { HostInfo, SiteResolver } from './site-resolver'; - -describe.only('SiteResolver', () => { - const hostInfo: HostInfo = { - hostName: 'foo.com', - }; - - const hostInfoWithLanguage: HostInfo = { - ...hostInfo, - language: 'en', - }; - - describe('resolve', () => { - describe('fallback site name', () => { - it('should return default when sites info is empty', () => { - const siteName = SiteResolver.resolve(hostInfo, []); - - expect(siteName).to.equal('website'); - }); - - it('should return default when there is no appropriate site info', () => { - const siteName = SiteResolver.resolve(hostInfo, [ - { hostName: 'bar.com', language: '', name: 'bar' }, - { hostName: 'var.com', language: '', name: 'var' }, - ]); - - expect(siteName).to.equal('website'); - }); - - it('should return custom when sites info is empty', () => { - const siteName = SiteResolver.resolve(hostInfo, [], 'sample'); - - expect(siteName).to.equal('sample'); - }); +import { SiteResolver } from './site-resolver'; + +describe('SiteResolver', () => { + describe('getByHost', () => { + it('should throw when sites info is empty', () => { + const resolver = new SiteResolver([]); + expect(() => { + resolver.getByHost('foo.com'); + }).to.throw(Error, 'Could not resolve site for host foo.com'); + }); - it('should return custom when there is no appropriate site info', () => { - const siteName = SiteResolver.resolve( - hostInfo, - [ - { hostName: 'bar.com', language: '', name: 'bar' }, - { hostName: 'var.com', language: '', name: 'var' }, - ], - 'sample' - ); - - expect(siteName).to.equal('sample'); - }); + it('should throw when there is no appropriate site info', () => { + const resolver = new SiteResolver([ + { hostName: 'bar.com', language: '', name: 'bar' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + expect(() => { + resolver.getByHost('foo.com'); + }).to.throw(Error, 'Could not resolve site for host foo.com'); }); - it('should return site name when only hostname is provided', () => { - const siteName = SiteResolver.resolve(hostInfo, [ + it('should return site', () => { + const resolver = new SiteResolver([ { hostName: 'bar.net', language: '', name: 'bar' }, { hostName: 'foo.com', language: '', name: 'foo' }, { hostName: 'var.com', language: '', name: 'var' }, ]); + const site = resolver.getByHost('foo.com'); - expect(siteName).to.equal('foo'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); }); - it('should return site name when hostname includes wildcard', () => { - const siteName = SiteResolver.resolve(hostInfo, [ - { hostName: 'var.com', language: 'da-DK', name: 'var' }, - { hostName: 'bar.net', language: 'en', name: 'bar' }, + it('should return site when hostname includes wildcard', () => { + const resolver = new SiteResolver([ + { hostName: 'var.com', language: '', name: 'var' }, + { hostName: 'bar.net', language: '', name: 'bar' }, { hostName: '*.com', language: '', name: 'foo' }, - { hostName: 'foo.com', language: 'en', name: 'foo-en' }, + { hostName: 'foo.com', language: '', name: 'foo-en' }, ]); + const site = resolver.getByHost('foo.com'); - expect(siteName).to.equal('foo'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); }); - it('should return site name when wildcard is provided', () => { - const siteName = SiteResolver.resolve(hostInfo, [ + it('should return site when wildcard is provided', () => { + const resolver = new SiteResolver([ { hostName: 'bar.net', language: '', name: 'bar' }, { hostName: '*', language: '', name: 'wildcard' }, { hostName: 'foo.com', language: '', name: 'foo' }, ]); + const site = resolver.getByHost('foo.com'); - expect(siteName).to.equal('wildcard'); - }); - - it('should return site name when language is provided', () => { - const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ - { hostName: 'foo.com', language: 'ca', name: 'foo-ca' }, - { hostName: 'var.com', language: 'da-DK', name: 'var' }, - { hostName: 'bar.net', language: 'en', name: 'bar' }, - { hostName: 'foo.com', language: 'en', name: 'foo-en' }, - ]); - - expect(siteName).to.equal('foo-en'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('wildcard'); }); - it('should return site name when language is omit', () => { - const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ - { hostName: 'var.com', language: 'da-DK', name: 'var' }, - { hostName: 'bar.net', language: 'en', name: 'bar' }, - { hostName: 'foo.com', language: '', name: 'foo' }, - { hostName: 'foo.com', language: 'en', name: 'foo-en' }, - ]); + it('should be case insensitive', () => { + const resolver = new SiteResolver([{ hostName: 'foo.com', language: '', name: 'foo' }]); + const site = resolver.getByHost('Foo.com'); - expect(siteName).to.equal('foo'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); }); - describe('should return site name when multi-value hostnames are provided', () => { + describe('multi-value hostname', () => { it('hostnames include wildcard characters', () => { - expect( - SiteResolver.resolve({ hostName: 'test.foo.bar.com' }, [ - { hostName: '*.bat.com|foo.bar.com', language: '', name: 'bar' }, - { hostName: 'test.com|*.foo.*.com|foo.com', language: '', name: 'foo' }, - ]) - ).to.equal('foo'); - - expect( - SiteResolver.resolve({ hostName: 'xfoo.bar.com.en' }, [ - { hostName: 'foo.bar.com', language: '', name: 'bar' }, - { hostName: 'test.com|*foo.*.com*|foo.com', language: '', name: 'foo' }, - ]) - ).to.equal('foo'); + let resolver = new SiteResolver([ + { hostName: '*.bat.com|foo.bar.com', language: '', name: 'bar' }, + { hostName: 'test.com|*.foo.*.com|foo.com', language: '', name: 'foo' }, + ]); + let site = resolver.getByHost('test.foo.bar.com'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); + + resolver = new SiteResolver([ + { hostName: 'foo.bar.com', language: '', name: 'bar' }, + { hostName: 'test.com|*foo.*.com*|foo.com', language: '', name: 'foo' }, + ]); + site = resolver.getByHost('xfoo.bar.com.en'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); }); it('hostname contains whitespaces', () => { - const siteName = SiteResolver.resolve(hostInfo, [ + const resolver = new SiteResolver([ { hostName: 'bar.net', language: '', name: 'bar' }, { hostName: 'test.com; foo.net | foo.com', language: '', name: 'foo' }, { hostName: 'var.com', language: '', name: 'var' }, ]); + const site = resolver.getByHost('foo.com'); - expect(siteName).to.equal('foo'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); }); it('comma delimiter is used', () => { - const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ + const resolver = new SiteResolver([ { hostName: 'bar.net', language: '', name: 'bar' }, - { hostName: 'test.com,foo.net,foo.com', language: 'en', name: 'foo' }, + { hostName: 'test.com,foo.net,foo.com', language: '', name: 'foo' }, { hostName: 'var.com', language: '', name: 'var' }, ]); + const site = resolver.getByHost('foo.com'); - expect(siteName).to.equal('foo'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); }); it('semicolon delimiter is used', () => { - const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ + const resolver = new SiteResolver([ { hostName: 'bar.net', language: '', name: 'bar' }, - { hostName: 'test.com;foo.net;foo.com', language: 'en', name: 'foo' }, + { hostName: 'test.com;foo.net;foo.com', language: '', name: 'foo' }, { hostName: 'var.com', language: '', name: 'var' }, ]); + const site = resolver.getByHost('foo.com'); - expect(siteName).to.equal('foo'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); }); it('pipe delimiter is used', () => { - const siteName = SiteResolver.resolve(hostInfoWithLanguage, [ - { hostName: 'bar.net', language: '', name: 'bar' }, - { hostName: 'test.com|foo.net|foo.com', language: 'en', name: 'foo' }, + const resolver = new SiteResolver([ + { hostName: 'bar.net', language: '', name: '' }, + { hostName: 'test.com|foo.net|foo.com', language: '', name: 'foo' }, { hostName: 'var.com', language: '', name: 'var' }, ]); + const site = resolver.getByHost('foo.com'); - expect(siteName).to.equal('foo'); + expect(site).to.not.be.undefined; + expect(site?.name).to.equal('foo'); }); }); }); + + describe('getByName', () => { + it('should throw when sites info is empty', () => { + const resolver = new SiteResolver([]); + expect(() => { + resolver.getByName('foo'); + }).to.throw(Error, 'Could not resolve site for name foo'); + }); + + it('should throw when there is no appropriate site info', () => { + const resolver = new SiteResolver([ + { hostName: 'bar.com', language: '', name: 'bar' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + expect(() => { + resolver.getByName('foo'); + }).to.throw(Error, 'Could not resolve site for name foo'); + }); + + it('should return site', () => { + const resolver = new SiteResolver([ + { hostName: 'bar.net', language: '', name: 'bar' }, + { hostName: 'foo.com', language: '', name: 'foo' }, + { hostName: 'var.com', language: '', name: 'var' }, + ]); + const site = resolver.getByName('foo'); + + expect(site).to.not.be.undefined; + expect(site?.hostName).to.equal('foo.com'); + }); + + it('should be case insensitive', () => { + const resolver = new SiteResolver([{ hostName: 'foo.com', language: '', name: 'foo' }]); + const site = resolver.getByName('Foo'); + + expect(site).to.not.be.undefined; + expect(site?.hostName).to.equal('foo.com'); + }); + }); }); diff --git a/packages/sitecore-jss/src/site/site-resolver.ts b/packages/sitecore-jss/src/site/site-resolver.ts index 231a2e89d6..534b064af6 100644 --- a/packages/sitecore-jss/src/site/site-resolver.ts +++ b/packages/sitecore-jss/src/site/site-resolver.ts @@ -1,55 +1,63 @@ import { SiteInfo } from './graphql-siteinfo-service'; -/** - * Information about the current host - */ -export type HostInfo = { - hostName: string; - language?: string; -}; - // Delimiters for multi-value hostnames const DELIMITERS = /\||,|;/g; /** - * Determines site name based on the provided host information + * Resolves site based on the provided host or site name */ export class SiteResolver { /** - * Resolve siteName by host information - * @param {HostInfo} hostInfo information about current host - * @param {SiteInfo[]} sitesInfo list of available sites - * @param {string} [fallbackSiteName] siteName to be returned in case siteName is not found - * @returns {string} siteName resolved site name + * @param {SiteInfo[]} sites Array of sites to be used in resolution */ - static resolve = ( - hostInfo: HostInfo, - sitesInfo: SiteInfo[], - fallbackSiteName = 'website' - ): string => { - const siteInfo = sitesInfo.find((info) => { - const hostnames = info.hostName.replace(/\s/g, '').split(DELIMITERS); + constructor(readonly sites: SiteInfo[]) {} - const languageMatches = - info.language === '' || !hostInfo.language || hostInfo.language === info.language; + /** + * Resolve site by host name + * @param {string} hostName the host name + * @returns {SiteInfo} the resolved site + * @throws {Error} if a matching site is not found + */ + public getByHost = (hostName: string): SiteInfo => { + const siteInfo = this.sites.find((info) => { + const hostnames = info.hostName.replace(/\s/g, '').split(DELIMITERS); return hostnames.some( - (hostname) => - languageMatches && - (hostInfo.hostName === hostname || - SiteResolver.matchesPattern(hostInfo.hostName, hostname)) + (hostname) => hostName === hostname || this.matchesPattern(hostName, hostname) ); }); - return siteInfo?.name || fallbackSiteName; + if (!siteInfo) { + throw new Error(`Could not resolve site for host ${hostName}`); + } + + return siteInfo; + }; + + /** + * Resolve site by site name + * @param {string} siteName the site name + * @returns {SiteInfo} the resolved site + * @throws {Error} if a matching site is not found + */ + public getByName = (siteName: string): SiteInfo => { + const siteInfo = this.sites.find( + (info) => info.name.toLocaleLowerCase() === siteName.toLocaleLowerCase() + ); + + if (!siteInfo) { + throw new Error(`Could not resolve site for name ${siteName}`); + } + + return siteInfo; }; - private static matchesPattern(hostname: string, pattern: string): boolean { + protected matchesPattern(hostname: string, pattern: string): boolean { // dots should be treated as chars // stars should be treated as wildcards const regExpPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); - const regExp = new RegExp(`^${regExpPattern}$`, 'g'); + const regExp = new RegExp(`^${regExpPattern}$`, 'gi'); return !!hostname.match(regExp); } From 5c2d3c0db41230dcfe8e40af33da4388e073e418 Mon Sep 17 00:00:00 2001 From: Adam Brauer <400763+ambrauer@users.noreply.github.com> Date: Mon, 9 Jan 2023 09:23:09 -0600 Subject: [PATCH 08/14] Update page props factory error-pages plugin to use props.site.name --- .../src/lib/page-props-factory/plugins/error-pages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/page-props-factory/plugins/error-pages.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/page-props-factory/plugins/error-pages.ts index ffe06eb737..96e2721fc6 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/page-props-factory/plugins/error-pages.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/page-props-factory/plugins/error-pages.ts @@ -12,7 +12,7 @@ class ErrorPagesPlugin implements Plugin { const errorPagesService = new GraphQLErrorPagesService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, + siteName: props.site.name, language: props.locale, }); From bc26952bfa0ae00da5cecd22be0873b8ca5b7ad0 Mon Sep 17 00:00:00 2001 From: Illia Kovalenko <23364749+illiakovalenko@users.noreply.github.com> Date: Tue, 10 Jan 2023 17:37:55 +0200 Subject: [PATCH 09/14] [Next.js][Multi-site] Multi-site middleware plugin (#1279) * jss-cli unit test coverage: first batch * jss-cli unit test coverage: second batch * jss-cli unit tests: second batch * fix expected output for test not to fail across environments * lint * dev-tools unit test coverage first batch * dev-tools unit test coverage: second batch * resolve-scjssconfig test placeholder (for the future!) * exclude index file from jss-vue test coverage (#1266) * version v21.1.0-canary.58 [skip ci] * fix test for scjssconfig resolving * Update packages/sitecore-jss-dev-tools/src/manifest/manifest-manager.test.ts Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> * Update packages/sitecore-jss-dev-tools/src/manifest/manifest-manager.test.ts Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> * lint * adding test data for scjssconfig * some improvements to reject logic in resolve scjssconfig * lint numero 2 * version v21.1.0-canary.59 [skip ci] * #556667: fixed urls for sitemap * version v21.1.0-canary.60 [skip ci] * final batch * re-gen yarn.lock * yarn.lock re-update * version v21.1.0-canary.61 [skip ci] * #552985: fixed header styles * version v21.1.0-canary.62 [skip ci] * #546298: fixed style for showing hidden components * version v21.1.0-canary.63 [skip ci] * SiteResolver.resolve updates: removed 'language' from site resolution logic, return SiteInfo instead of site name * [Next.js][Multi-site] Multi-site middleware plugin * #559044: fixed rendering dynamic placeholder (#1278) * version v21.1.0-canary.64 [skip ci] * Adjust with latest site resolver changes * adjust * Add latest changes * Extra comment * extra fix * Extend unit tests * Revert cookie set change * Use response cookies instead of request * Adjust changes according to review * Adjust changes according to review * lint fix Co-authored-by: Artem Alexeyenko Co-authored-by: Automated Build Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> Co-authored-by: Ruslan Matkovskyi Co-authored-by: Ruslan Matkovskyi <100142572+matkovskyi@users.noreply.github.com> --- lerna.json | 2 +- packages/create-sitecore-jss/package.json | 2 +- .../src/lib/middleware/plugins/multisite.ts | 37 ++ .../nextjs-multisite/src/lib/site-resolver.ts | 2 +- .../src/lib/middleware/plugins/personalize.ts | 4 +- .../nextjs-sxa/src/assets/basic/_header.scss | 3 +- .../sass/components/_component-container.scss | 4 + .../src/lib/middleware/plugins/redirects.ts | 4 +- .../nextjs-sxa/src/pages/api/sitemap.ts | 17 +- .../package.json | 2 +- packages/sitecore-jss-angular/package.json | 4 +- packages/sitecore-jss-cli/.nycrc | 7 +- packages/sitecore-jss-cli/package.json | 8 +- packages/sitecore-jss-cli/src/cli.test.ts | 73 ++++ packages/sitecore-jss-cli/src/cli.ts | 33 +- .../src/micro-manifest.test.ts | 130 +++++++ .../sitecore-jss-cli/src/micro-manifest.ts | 72 ++-- .../src/run-package-script.test.ts | 17 + .../src/run-package-script.ts | 9 +- .../src/scripts/clean.test.ts | 44 +++ .../src/scripts/deploy.app.test.ts | 36 ++ .../src/scripts/deploy.component.test.ts | 59 +++ .../src/scripts/deploy.component.ts | 2 +- .../src/scripts/deploy.config.test.ts | 72 ++++ .../src/scripts/deploy.files.test.ts | 105 ++++++ .../src/scripts/deploy.items.test.ts | 99 +++++ .../src/scripts/deploy.template.test.ts | 59 +++ .../src/scripts/deploy.template.ts | 2 +- .../src/scripts/elephant.test.ts | 16 + .../src/scripts/environment.test.ts | 16 + .../src/scripts/manifest.test.ts | 117 ++++++ .../src/scripts/package.test.ts | 62 ++++ packages/sitecore-jss-cli/src/spawn.test.ts | 64 ++++ packages/sitecore-jss-dev-tools/.nycrc | 9 +- packages/sitecore-jss-dev-tools/package.json | 4 +- .../sitecore-jss-dev-tools/src/clean.test.ts | 42 +++ .../sitecore-jss-dev-tools/src/deploy.test.ts | 97 +++++ .../src/manifest/generator/manifest.test.ts | 198 ++++++++++ .../processNestedContent.test.ts | 79 ++++ .../src/manifest/generator/traversal.test.ts | 172 +++++++++ .../src/manifest/manifest-manager.test.ts | 90 +++++ .../src/package-generate.test.ts | 30 ++ .../src/resolve-scjssconfig.test.ts | 75 ++++ .../src/resolve-scjssconfig.ts | 4 +- .../src/test-data/scjssconfig-empty.json | 1 + .../src/test-data/scjssconfig-working.json | 9 + packages/sitecore-jss-forms/package.json | 4 +- packages/sitecore-jss-nextjs/package.json | 8 +- .../src/middleware/index.ts | 2 + .../middleware/multisite-middleware.test.ts | 348 ++++++++++++++++++ .../src/middleware/multisite-middleware.ts | 125 +++++++ .../middleware/personalize-middleware.test.ts | 278 +++++++++++++- .../src/middleware/personalize-middleware.ts | 35 +- .../middleware/redirects-middleware.test.ts | 138 ++++++- .../src/middleware/redirects-middleware.ts | 119 +++--- packages/sitecore-jss-proxy/package.json | 2 +- .../sitecore-jss-react-forms/package.json | 4 +- .../sitecore-jss-react-native/package.json | 4 +- packages/sitecore-jss-react/package.json | 4 +- .../src/components/PlaceholderCommon.tsx | 15 +- .../src/test-data/non-ee-data.ts | 2 +- .../sitecore-jss-rendering-host/package.json | 2 +- .../sitecore-jss-vue/jest.config.coverage.js | 1 + packages/sitecore-jss-vue/package.json | 4 +- packages/sitecore-jss/package.json | 2 +- .../graphql-personalize-service.test.ts | 35 +- .../graphql-personalize-service.ts | 23 +- .../site/graphql-redirects-service.test.ts | 59 ++- .../src/site/graphql-redirects-service.ts | 9 +- yarn.lock | 30 +- 70 files changed, 3018 insertions(+), 228 deletions(-) create mode 100644 packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/middleware/plugins/multisite.ts create mode 100644 packages/sitecore-jss-cli/src/cli.test.ts create mode 100644 packages/sitecore-jss-cli/src/micro-manifest.test.ts create mode 100644 packages/sitecore-jss-cli/src/run-package-script.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/clean.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/deploy.app.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/deploy.component.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/deploy.config.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/deploy.files.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/deploy.items.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/deploy.template.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/elephant.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/environment.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/manifest.test.ts create mode 100644 packages/sitecore-jss-cli/src/scripts/package.test.ts create mode 100644 packages/sitecore-jss-cli/src/spawn.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/clean.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/deploy.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/manifest/generator/manifest.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/manifest/generator/pipelines/generateContentItem/processNestedContent.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/manifest/generator/traversal.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/manifest/manifest-manager.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/package-generate.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/resolve-scjssconfig.test.ts create mode 100644 packages/sitecore-jss-dev-tools/src/test-data/scjssconfig-empty.json create mode 100644 packages/sitecore-jss-dev-tools/src/test-data/scjssconfig-working.json create mode 100644 packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.test.ts create mode 100644 packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.ts diff --git a/lerna.json b/lerna.json index 0afd45e52e..4bdc4c0001 100644 --- a/lerna.json +++ b/lerna.json @@ -4,7 +4,7 @@ "packages/*", "samples/*" ], - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "npmClient": "yarn", "useWorkspaces": true } diff --git a/packages/create-sitecore-jss/package.json b/packages/create-sitecore-jss/package.json index 7884c6f00b..4c6092b72c 100644 --- a/packages/create-sitecore-jss/package.json +++ b/packages/create-sitecore-jss/package.json @@ -1,6 +1,6 @@ { "name": "create-sitecore-jss", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "description": "Sitecore JSS initializer", "bin": "./dist/index.js", "scripts": { diff --git a/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/middleware/plugins/multisite.ts b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/middleware/plugins/multisite.ts new file mode 100644 index 0000000000..903d1fc16e --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/middleware/plugins/multisite.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { MultisiteMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/middleware'; +import { siteResolver } from 'lib/site-resolver'; +import { MiddlewarePlugin } from '..'; + +/** + * This is the multisite middleware plugin for Next.js. + * It is used to enable Sitecore multisite in Next.js. + * + * The `MultisiteMiddleware` will + * 1. Based on provided hostname and sites information, resolve site. + * 2. Rewrite the response to the specific site. + * 3. Set `sc_site` cookie with site name and `x-sc-rewrite` header with rewritten path to be reused in following middlewares. + */ +class MultisitePlugin implements MiddlewarePlugin { + private multisiteMiddleware: MultisiteMiddleware; + + // Multisite middleware has to be executed first + order = -1; + + constructor() { + this.multisiteMiddleware = new MultisiteMiddleware({ + // This function determines if a route should be excluded from site resolution. + // Certain paths are ignored by default (e.g. files and Next.js API routes), but you may wish to exclude more. + // This is an important performance consideration since Next.js Edge middleware runs on every request. + excludeRoute: () => false, + // This function resolves site based on hostname + getSite: siteResolver.getByHost, + }); + } + + async exec(req: NextRequest, res?: NextResponse): Promise { + return this.multisiteMiddleware.getHandler()(req, res); + } +} + +export const multisitePlugin = new MultisitePlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/site-resolver.ts b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/site-resolver.ts index ec447f4507..05d3fe5ea1 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/site-resolver.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/site-resolver.ts @@ -1,4 +1,4 @@ -import { SiteResolver, SiteInfo } from '@sitecore-jss/sitecore-jss-nextjs'; +import { SiteResolver, SiteInfo } from '@sitecore-jss/sitecore-jss-nextjs/middleware'; import config from 'temp/config'; /* diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/middleware/plugins/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/middleware/plugins/personalize.ts index 2d10841d2c..66739dffa2 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/middleware/plugins/personalize.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/middleware/plugins/personalize.ts @@ -3,6 +3,7 @@ import { PersonalizeMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/middlew import { MiddlewarePlugin } from '..'; import config from 'temp/config'; import { PosResolver } from 'lib/pos-resolver'; +import { siteResolver } from 'lib/site-resolver'; /** * This is the personalize middleware plugin for Next.js. @@ -26,7 +27,6 @@ class PersonalizePlugin implements MiddlewarePlugin { edgeConfig: { endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, timeout: (process.env.PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT && parseInt(process.env.PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT)) || @@ -52,6 +52,8 @@ class PersonalizePlugin implements MiddlewarePlugin { // This function resolves point of sale for cdp calls. // Point of sale may differ by locale and middleware will use request language to get the correct value every time it's invoked getPointOfSale: PosResolver.resolve, + // This function resolves site based on hostname + getSite: siteResolver.getByHost, }); } diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/assets/basic/_header.scss b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/assets/basic/_header.scss index 27fa707818..9a20cea821 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/assets/basic/_header.scss +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/assets/basic/_header.scss @@ -5,7 +5,6 @@ .prod-mode { #header { display: flex; - flex-direction: column; @include respond-to(mobile-large) { padding-bottom: 0; @@ -33,7 +32,7 @@ header { padding-top: 0; flex-direction: column-reverse; - .title { + .bs-title { padding-left: 0; text-align: center; margin-top: -5px; diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/assets/sass/components/_component-container.scss b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/assets/sass/components/_component-container.scss index 7bea8408b8..5b3b15ea40 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/assets/sass/components/_component-container.scss +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/assets/sass/components/_component-container.scss @@ -4,6 +4,10 @@ width: 100%; } +.component { + position: relative; +} + .container { padding: 0; diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/middleware/plugins/redirects.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/middleware/plugins/redirects.ts index c6cbdc8d5a..cea2330c21 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/middleware/plugins/redirects.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/middleware/plugins/redirects.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { RedirectsMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/middleware'; import config from 'temp/config'; import { MiddlewarePlugin } from '..'; +import { siteResolver } from 'lib/site-resolver'; class RedirectsPlugin implements MiddlewarePlugin { private redirectsMiddleware: RedirectsMiddleware; @@ -11,7 +12,6 @@ class RedirectsPlugin implements MiddlewarePlugin { this.redirectsMiddleware = new RedirectsMiddleware({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, // These are all the locales you support in your application. // These should match those in your next.config.js (i18n.locales). locales: ['en'], @@ -22,6 +22,8 @@ class RedirectsPlugin implements MiddlewarePlugin { // This function determines if the middleware should be turned off. // By default it is disabled while in development mode. disabled: () => process.env.NODE_ENV === 'development', + // This function resolves site based on hostname + getSite: siteResolver.getByHost, }); } diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts index 5cdccd2ae6..5a80f387f2 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts @@ -1,6 +1,6 @@ import { AxiosResponse } from 'axios'; import type { NextApiRequest, NextApiResponse } from 'next'; -import { AxiosDataFetcher, GraphQLSitemapXmlService } from '@sitecore-jss/sitecore-jss-nextjs'; +import { AxiosDataFetcher, GraphQLSitemapXmlService, getPublicUrl } from '@sitecore-jss/sitecore-jss-nextjs'; import { siteResolver } from 'lib/site-resolver'; import config from 'temp/config'; @@ -53,13 +53,14 @@ const sitemapApi = async ( } const SitemapLinks = sitemaps - .map( - (item) => - ` - ${item} - - ` - ) + .map((item) => { + const parseUrl = item.split('/'); + const lastSegment = parseUrl[parseUrl.length - 1]; + + return ` + ${getPublicUrl()}/${lastSegment} + `; + }) .join(''); res.setHeader('Content-Type', 'text/xml;charset=utf-8'); diff --git a/packages/sitecore-jss-angular-schematics/package.json b/packages/sitecore-jss-angular-schematics/package.json index d798013d9d..203434f2fb 100644 --- a/packages/sitecore-jss-angular-schematics/package.json +++ b/packages/sitecore-jss-angular-schematics/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-angular-schematics", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "description": "Scaffolding schematics for Sitecore JSS Angular apps", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/sitecore-jss-angular/package.json b/packages/sitecore-jss-angular/package.json index 378a70eb1a..db445d57d9 100644 --- a/packages/sitecore-jss-angular/package.json +++ b/packages/sitecore-jss-angular/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-angular", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "description": "", "scripts": { "build": "ng-packagr -p ng-package.json", @@ -58,7 +58,7 @@ "rxjs": "~6.6.6" }, "dependencies": { - "@sitecore-jss/sitecore-jss": "^21.1.0-canary.57" + "@sitecore-jss/sitecore-jss": "^21.1.0-canary.64" }, "main": "dist/bundles/sitecore-jss-sitecore-jss-angular.umd.js", "module": "dist/fesm2015/sitecore-jss-sitecore-jss-angular.js", diff --git a/packages/sitecore-jss-cli/.nycrc b/packages/sitecore-jss-cli/.nycrc index 5368323135..172dad2e1b 100644 --- a/packages/sitecore-jss-cli/.nycrc +++ b/packages/sitecore-jss-cli/.nycrc @@ -7,7 +7,12 @@ "**/*.test.ts", "src/test-data", "dist", - "src/test.ts" + "src/test.ts", + "**/create.ts", + "**/deploy.package.ts", + "**/index.global.ts", + "**/index.ts", + "**/cli.global.ts" ], "all": true, "reporter": [ diff --git a/packages/sitecore-jss-cli/package.json b/packages/sitecore-jss-cli/package.json index a2ce78bab8..fb745b3f71 100644 --- a/packages/sitecore-jss-cli/package.json +++ b/packages/sitecore-jss-cli/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-cli", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "description": "Sitecore JSS command-line", "main": "dist/cjs/cli.js", "module": "dist/esm/cli.js", @@ -12,8 +12,8 @@ "lint": "eslint ./src/**/*.ts", "prepublishOnly": "npm run build", "jss": "node ./dist/cjs/bin/jss.js", - "test": "mocha --require ts-node/register \"./src/**/*.test.ts\"", - "coverage": "nyc npm test" + "test": "mocha --require ts-node/register/transpile-only \"./src/**/*.test.ts\"", + "coverage": "nyc --require ts-node/register/transpile-only npm test" }, "engines": { "node": ">=12", @@ -33,7 +33,7 @@ "url": "https://github.com/sitecore/jss/issues" }, "dependencies": { - "@sitecore-jss/sitecore-jss-dev-tools": "^21.1.0-canary.57", + "@sitecore-jss/sitecore-jss-dev-tools": "^21.1.0-canary.64", "chalk": "^2.4.2", "cross-spawn": "^7.0.0", "dotenv": "^16.0.1", diff --git a/packages/sitecore-jss-cli/src/cli.test.ts b/packages/sitecore-jss-cli/src/cli.test.ts new file mode 100644 index 0000000000..178b91c209 --- /dev/null +++ b/packages/sitecore-jss-cli/src/cli.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable no-unused-expressions */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getPackageScriptCommands, makeCommand } from './cli'; +import * as resolvePkg from './resolve-package'; +import * as packageScript from './run-package-script'; +import { Arguments } from 'yargs'; + +describe('cli', () => { + describe('getPackageScriptCommands', async () => { + afterEach(() => { + sinon.restore(); + }); + const packageJson = { + scripts: { + first: 'do --this', + second: 'do --that', + third: 'do --all', + }, + }; + + it('should read scripts from package.json and return result with handlers', async () => { + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + const result = await getPackageScriptCommands(); + const runScriptStub = sinon.stub(packageScript, 'default'); + const mockArgs: Arguments = { + _: ['arg1', 'arg2'], + $0: '', + }; + + expect(Object.keys(packageJson.scripts)).to.be.deep.equal(Object.keys(result)); + for (const key of Object.keys(result)) { + const expectedCommand = makeCommand(key); + for (const field of Object.keys(expectedCommand)) { + if (typeof expectedCommand[field] === 'function') { + expectedCommand[field](mockArgs); + expect(runScriptStub.called).to.be.true; + } else { + expect(result[key][field]).to.deep.equal(expectedCommand[field]); + } + } + } + }); + + it('should return empty result when package.json contents are empty', async () => { + const emptyPackage = {}; + + sinon.stub(resolvePkg, 'default').resolves(emptyPackage); + + const result = await getPackageScriptCommands(); + + expect(result).to.deep.equal(emptyPackage); + }); + + it('should ignore jss script entry', async () => { + const packageJson = { + scripts: { + jss: 'do --this', + second: 'do --that', + third: 'do --all', + }, + }; + const { jss: _, ...expectedScripts } = packageJson.scripts; + + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + const result = await getPackageScriptCommands(); + + expect(Object.keys(expectedScripts)).to.be.deep.equal(Object.keys(result)); + }); + }); +}); diff --git a/packages/sitecore-jss-cli/src/cli.ts b/packages/sitecore-jss-cli/src/cli.ts index 3fa84dba77..ba6fa91b11 100644 --- a/packages/sitecore-jss-cli/src/cli.ts +++ b/packages/sitecore-jss-cli/src/cli.ts @@ -7,7 +7,7 @@ import * as commands from './scripts'; /** * Get package script commands */ -async function getPackageScriptCommands() { +export async function getPackageScriptCommands() { const packageJson = await resolvePackage(); const result: { [key: string]: CommandModule } = {}; @@ -20,18 +20,7 @@ async function getPackageScriptCommands() { return; } - const command = { - command: script, - describe: 'package.json script', - builder: {}, - disableStrictArgs: true, - handler: (argv: Arguments) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((argv as any)._[0]) { - runPackageScript(process.argv.slice(2)); - } - }, - }; + const command = makeCommand(script); result[script] = command; }); @@ -39,6 +28,24 @@ async function getPackageScriptCommands() { return result; } +/** + * @param script + */ +export function makeCommand(script: string) { + return { + command: script, + describe: 'package.json script', + builder: {}, + disableStrictArgs: true, + handler: (argv: Arguments) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((argv as any)._[0]) { + runPackageScript(process.argv.slice(2)); + } + }, + }; +} + /** * implements CLI commands when executed from a local node_modules folder */ diff --git a/packages/sitecore-jss-cli/src/micro-manifest.test.ts b/packages/sitecore-jss-cli/src/micro-manifest.test.ts new file mode 100644 index 0000000000..5237874e85 --- /dev/null +++ b/packages/sitecore-jss-cli/src/micro-manifest.test.ts @@ -0,0 +1,130 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import fs from 'fs'; +import * as microManifest from './micro-manifest'; +import * as resolvePkg from './resolve-package'; +import * as manifestHandler from './scripts/manifest'; +import * as packageHandler from './scripts/package'; + +import * as packageDeploy from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/package-deploy'; +import * as verify from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/setup/verify-setup'; +import * as resolveJssConfig from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/resolve-scjssconfig'; +import tmp from 'tmp'; +import path from 'path'; + +describe('micro-manifest script', () => { + afterEach(() => { + sinon.restore(); + }); + + const packageJson = { + config: { + appName: 'jss-unit-package', + }, + }; + + const scJssConfig = { + sitecore: { + deploySecret: 'you-are-85%-water', + deployUrl: 'deploy.jss.com', + }, + }; + + const argv = { + appName: 'jss-manifest', + deployUrl: 'customs.jss.com', + deploySecret: 'snape-kills-dumbledore', + debugSecurity: true, + acceptCertificate: 'yes', + }; + + const tmpDirReturnsDefault = { + err: false, + tempDir: 'C:/temp', + cleanupTempDir: sinon.stub(), + }; + + describe('verifyArgs', () => { + it('should use fallaback for appName, deployUrl, deploySecret, if not proided', async () => { + const localArgv = { + ...argv, + appName: undefined, + deployUrl: undefined, + deploySecret: undefined, + }; + + const expectedArgv = { + ...argv, + appName: packageJson.config.appName, + deployUrl: scJssConfig.sitecore.deployUrl, + deploySecret: scJssConfig.sitecore.deploySecret, + }; + + sinon.stub(verify, 'verifySetup'); + sinon.stub(resolveJssConfig, 'resolveScJssConfig').resolves(scJssConfig); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + expect(await microManifest.verifyArgs(localArgv)).to.deep.equal(expectedArgv); + }); + }); + + describe('microManifest', () => { + it('should create temp directory and finalize manifest creation', async () => { + const tmpDirReturns = { + ...tmpDirReturnsDefault, + cleanUpTempDir: sinon.stub(), + }; + + sinon.stub(resolveJssConfig, 'resolveScJssConfig').resolves(scJssConfig); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + const tmpStub = sinon + .stub(tmp, 'dir') + .callsArgWith(1, tmpDirReturns.err, tmpDirReturns.tempDir, tmpDirReturns.cleanupTempDir); + const writeFileStub = sinon.stub(fs, 'writeFileSync'); + sinon.stub(fs, 'existsSync').returns(true); + const manifestStub = sinon.stub(manifestHandler, 'handler'); + const packageStub = sinon.stub(packageHandler, 'handler'); + const deployStub = sinon.stub(packageDeploy, 'packageDeploy'); + + const manifestFolder = path.join(tmpDirReturnsDefault.tempDir, 'manifest'); + const packageDir = path.join(tmpDirReturnsDefault.tempDir, 'package'); + const manifestContents = 'stub'; + + const manifestArgs = { + manifestSourceFiles: [path.join(manifestFolder, 'tempManifestSource.js')], + manifestOutputPath: path.join(manifestFolder, 'tempManifest.json'), + noDictionary: true, + ...argv, + }; + + const packageArgs = { + skipManifest: true, + noFiles: true, + packageOutputPath: path.join(packageDir, 'tempPackage.manifest.zip'), + ...manifestArgs, + }; + + const deployArgs = { + appName: argv.appName, + packagePath: packageArgs.packageOutputPath, + importServiceUrl: argv.deployUrl, + secret: argv.deploySecret, + debugSecurity: argv.debugSecurity, + acceptCertificate: argv.acceptCertificate, + }; + + await microManifest.default(argv, manifestContents); + + expect(tmpStub.called).to.be.true; + expect( + writeFileStub.calledWith(manifestArgs.manifestSourceFiles[0], manifestContents, 'utf8') + ).to.be.true; + expect(manifestStub.calledWith(manifestArgs)).to.be.true; + expect(packageStub.calledWith(packageArgs)).to.be.true; + expect(deployStub.calledWith(deployArgs)).to.be.true; + expect(tmpDirReturns.cleanupTempDir.called).to.be.true; + }); + }); +}); diff --git a/packages/sitecore-jss-cli/src/micro-manifest.ts b/packages/sitecore-jss-cli/src/micro-manifest.ts index fb63046f99..e3e7dad0f5 100644 --- a/packages/sitecore-jss-cli/src/micro-manifest.ts +++ b/packages/sitecore-jss-cli/src/micro-manifest.ts @@ -21,38 +21,7 @@ export default async function microManifest( ) { verifySetup(); - const packageJson = await resolvePackage(); - - if (!argv.appName) { - argv.appName = packageJson.config.appName; - } - if (!argv.appName) { - throw new Error('App Name was not defined as a parameter or in the package.json config'); - } - - const jssConfig = await resolveScJssConfig({ configPath: argv.config as string }); - - if (!argv.deployUrl) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const legacyConfig = jssConfig.sitecore as any; - argv.deployUrl = legacyConfig.shipUrl ? legacyConfig.shipUrl : jssConfig.sitecore.deployUrl; - } - if (!argv.deployUrl) { - throw new Error('deployUrl was not defined as a parameter or in the scjssconfig.json file'); - } - - if (/\/ship\/services\/package/.test(argv.deployUrl)) { - throw new Error( - 'deployUrl appears to be a Sitecore.Ship endpoint. JSS no longer uses Ship. You will need to reconfigure your endpoint to the JSS deploy service and provide an app shared secret to deploy.' - ); - } - - if (!argv.deploySecret) { - argv.deploySecret = jssConfig.sitecore.deploySecret; - } - if (!argv.deploySecret) { - throw new Error('deploySecret was not defined as a parameter or in the scjssconfig.json file'); - } + argv = await verifyArgs(argv); return new Promise((resolve, reject) => { tmp.dir({ unsafeCleanup: true }, async (err, tempDir, cleanupTempDir) => { @@ -110,3 +79,42 @@ export default async function microManifest( }); }); } + +/** + * + */ +export async function verifyArgs(argv: { [key: string]: any }) { + const packageJson = await resolvePackage(); + if (!argv.appName) { + argv.appName = packageJson.config.appName; + } + if (!argv.appName) { + throw new Error('App Name was not defined as a parameter or in the package.json config'); + } + + const jssConfig = await resolveScJssConfig({ configPath: argv.config as string }); + + if (!argv.deployUrl) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const legacyConfig = jssConfig.sitecore as any; + argv.deployUrl = legacyConfig.shipUrl ? legacyConfig.shipUrl : jssConfig.sitecore.deployUrl; + } + if (!argv.deployUrl) { + throw new Error('deployUrl was not defined as a parameter or in the scjssconfig.json file'); + } + + if (/\/ship\/services\/package/.test(argv.deployUrl)) { + throw new Error( + 'deployUrl appears to be a Sitecore.Ship endpoint. JSS no longer uses Ship. You will need to reconfigure your endpoint to the JSS deploy service and provide an app shared secret to deploy.' + ); + } + + if (!argv.deploySecret) { + argv.deploySecret = jssConfig.sitecore.deploySecret; + } + if (!argv.deploySecret) { + throw new Error('deploySecret was not defined as a parameter or in the scjssconfig.json file'); + } + + return argv; +} diff --git a/packages/sitecore-jss-cli/src/run-package-script.test.ts b/packages/sitecore-jss-cli/src/run-package-script.test.ts new file mode 100644 index 0000000000..599555b486 --- /dev/null +++ b/packages/sitecore-jss-cli/src/run-package-script.test.ts @@ -0,0 +1,17 @@ +/* eslint-disable no-unused-expressions */ +import * as spawn from './spawn'; +import sinon from 'sinon'; +import runPackageScript, { transformPackageArgs } from './run-package-script'; +import { expect } from 'chai'; + +describe('run-package-script', () => { + it('runPackageScript should invoke spawn with args', () => { + const spawnMock = sinon.stub(spawn, 'default'); + + const mockArgs = ['arg1', 'arg2']; + + runPackageScript(mockArgs); + + expect(spawnMock.calledWith('npm', transformPackageArgs(mockArgs))).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-cli/src/run-package-script.ts b/packages/sitecore-jss-cli/src/run-package-script.ts index 4659178192..efc00ab59e 100644 --- a/packages/sitecore-jss-cli/src/run-package-script.ts +++ b/packages/sitecore-jss-cli/src/run-package-script.ts @@ -12,7 +12,7 @@ export default function runPackageScript( options?: SpawnSyncOptionsWithStringEncoding ) { // npm needs a -- delimiter before any extra args - const npmArgs = ['run', ...args.slice(0, 1), '--', ...args.slice(1)]; + const npmArgs = transformPackageArgs(args); runPackageManagerCommand(npmArgs, options); } @@ -30,3 +30,10 @@ export function runPackageManagerCommand( console.log(`> npm ${npmArgs.join(' ')}`); spawn('npm', npmArgs, options); } + +/** + * @param args + */ +export function transformPackageArgs(args: string[]) { + return ['run', ...args.slice(0, 1), '--', ...args.slice(1)]; +} diff --git a/packages/sitecore-jss-cli/src/scripts/clean.test.ts b/packages/sitecore-jss-cli/src/scripts/clean.test.ts new file mode 100644 index 0000000000..1e681e82b6 --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/clean.test.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable prettier/prettier */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as resolvePackage from '../resolve-package'; +import { handler } from './clean'; + +import * as devTools from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/clean'; + +describe('clean script', () => { + + afterEach(() => { + sinon.restore(); + }); + + it('clean should be called with path from argv', async () => { + const stub = sinon.stub(devTools, 'clean'); + const argv = { + path: 'C:/The-Curious-Case-of-Benjamin-Button', + }; + + await handler(argv); + expect(stub.calledWith(argv)).to.equal(true); + }); + + it('should exit on missing path', async () => { + const processStub = sinon.stub(process, 'exit'); + const logSpy = sinon.spy(console, 'error'); + const errorMsg = 'Path argument was not specified and no \'buildArtifactsPath\' in package.json.'; + const argv = { path: '' }; + + // ensure clean is not executed - since we stub process.exit - and the script execution will continue + const stub = sinon.stub(devTools, 'clean'); + sinon.stub(resolvePackage, 'default').resolves({ config: { buildArtifactsPath: '' } }); + const cleanImpl = require('./clean'); + + await cleanImpl.handler(argv); + + expect(processStub.calledWith(1)).to.be.true; + expect(logSpy.getCall(0).args[0].toString()).to.contain(errorMsg); + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/deploy.app.test.ts b/packages/sitecore-jss-cli/src/scripts/deploy.app.test.ts new file mode 100644 index 0000000000..7044f96358 --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/deploy.app.test.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as deployFiles from './deploy.files'; +import * as deployItems from './deploy.items'; +import { handler } from './deploy.app'; + +describe('deploy.app script', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should deploy both items and files', async () => { + const deployFilesStub = sinon.stub(deployFiles, 'handler').resolves(); + const deployItemsStub = sinon.stub(deployItems, 'handler').resolves(); + const argv = {}; + await handler(argv); + + expect(deployItemsStub.calledWith(argv)).to.be.true; + expect(deployFilesStub.calledWith(argv)).to.be.true; + }); + + it('should log error and exit on deployItems error', async () => { + const errorMsg = 'Cant connect to Sitecore if youre a unit test :('; + const deployItemsStub = sinon.stub(deployItems, 'handler').rejects(errorMsg); + const processStub = sinon.stub(process, 'exit'); + const logSpy = sinon.spy(console, 'log'); + await handler({}); + + expect(deployItemsStub.called).to.be.true; + expect(processStub.calledWith(1)).to.be.true; + expect(logSpy.getCall(0).args[0].toString()).to.contain(errorMsg); + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/deploy.component.test.ts b/packages/sitecore-jss-cli/src/scripts/deploy.component.test.ts new file mode 100644 index 0000000000..0ebd3361f9 --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/deploy.component.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as microManifest from '../micro-manifest'; +import { handler } from './deploy.component'; + +describe('deploy.component script', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should parse fields from input', async () => { + const argv = { + fields: ['single', 'multi:multi-line text'], + skipDeploy: true, + }; + const expectedFields = [ + { + name: 'single', + type: 'Single-LineText', + }, + { + name: 'multi', + type: 'multi-linetext', + }, + ]; + const logSpy = sinon.spy(console, 'log'); + + await handler(argv); + + const logOutput = logSpy + .getCall(0) + .args[0].toString() + .replace(/\s/g, ''); + + expect(logOutput).to.contain(JSON.stringify(expectedFields)); + }); + + // the actual work is done in microManifest - so we just test success messages + // and test microManifest separately + it('should log on successful deploy', async () => { + sinon.stub(microManifest, 'default').resolves(); + const logSpy = sinon.spy(console, 'log'); + const argv = { + name: 'unit', + displayName: 'absolute unit', + icon: '', + fields: [], + placeholders: [], + allowedPlaceholders: [], + }; + const successMsg = 'Your component has been created (or updated)!'; + await handler(argv); + + expect(logSpy.getCall(0).args[0].toString()).to.contain(successMsg); + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/deploy.component.ts b/packages/sitecore-jss-cli/src/scripts/deploy.component.ts index eab556d1c7..1cd3476829 100644 --- a/packages/sitecore-jss-cli/src/scripts/deploy.component.ts +++ b/packages/sitecore-jss-cli/src/scripts/deploy.component.ts @@ -38,7 +38,7 @@ export function args(yargs: Argv) { * @param {any} argv */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -async function handler(argv: any) { +export async function handler(argv: any) { // create micro-manifest to deploy from const fields: Array<{ name: string; type: string }> = []; diff --git a/packages/sitecore-jss-cli/src/scripts/deploy.config.test.ts b/packages/sitecore-jss-cli/src/scripts/deploy.config.test.ts new file mode 100644 index 0000000000..a52108afe9 --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/deploy.config.test.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { handler } from './deploy.config'; +import path from 'path'; +import * as resolvePkg from '../resolve-package'; +import * as deployTools from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/deploy'; +import * as verify from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/setup/verify-setup'; +import * as scJssConfigTool from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/resolve-scjssconfig'; + +describe('deploy.config script', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should call deploy with parsed options', async () => { + const argv = { + destination: 'S:/Santiago', + source: 'F:/Biarritz', + }; + const expectedOptions = { + destinationPath: argv.destination, + sourcePath: argv.source, + clean: false, + }; + const deployStub = sinon.stub(deployTools, 'deploy'); + sinon.stub(verify, 'verifySetup'); + + await handler(argv); + + expect(deployStub.calledWith(expectedOptions)).to.be.true; + }); + + it('should attempt to resolve destination when not provided', async () => { + const argv = { + destination: '', + source: 'F:/Biarritz', + }; + + const scJssConfig = { + sitecore: { + instancePath: 'S:/', + }, + }; + const packageJson = { + config: { + sitecoreConfigPath: 'Santiago', + }, + }; + + const expectedOptions = { + destinationPath: path.join( + scJssConfig.sitecore.instancePath, + packageJson.config.sitecoreConfigPath + ), + sourcePath: argv.source, + clean: false, + }; + + const deployStub = sinon.stub(deployTools, 'deploy'); + sinon.stub(verify, 'verifySetup'); + sinon.stub(scJssConfigTool, 'resolveScJssConfig').resolves(scJssConfig); + + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + await handler(argv); + + expect(deployStub.calledWith(expectedOptions)).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/deploy.files.test.ts b/packages/sitecore-jss-cli/src/scripts/deploy.files.test.ts new file mode 100644 index 0000000000..7b3bd76d41 --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/deploy.files.test.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { handler } from './deploy.files'; +import * as resolvePkg from '../resolve-package'; +import path from 'path'; +import * as deployTools from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/deploy'; +import * as verify from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/setup/verify-setup'; +import * as scJssConfigTool from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/resolve-scjssconfig'; + +describe('deploy.files script', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should call deploy with parsed options', async () => { + const argv = { + destination: 'S:/Santiago', + source: 'F:/Biarritz', + exclude: ['this one', 'that one'], + clean: false, + }; + const expectedOptions = { + destinationPath: argv.destination, + sourcePath: argv.source, + excludeFile: argv.exclude, + clean: argv.clean, + }; + + const packageJson = { + config: { + sitecoreConfigPath: 'Santiago', + sitecoreDistPath: 'C:/SanFrancisco', + }, + }; + const deployStub = sinon.stub(deployTools, 'deploy'); + sinon.stub(verify, 'verifySetup'); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + await handler(argv); + + expect(deployStub.calledWith(expectedOptions)).to.be.true; + }); + + it('should attempt to resolve destination when not provided', async () => { + const argv = { + destination: '', + source: 'F:/Biarritz', + exclude: ['this one', 'that one'], + clean: false, + }; + + const scJssConfig = { + sitecore: { + instancePath: 'S:/', + }, + }; + const packageJson = { + config: { + sitecoreConfigPath: 'Santiago', + sitecoreDistPath: 'SanFrancisco', + }, + }; + + const expectedOptions = { + destinationPath: path.join( + scJssConfig.sitecore.instancePath, + packageJson.config.sitecoreDistPath + ), + sourcePath: argv.source, + excludeFile: argv.exclude, + clean: false, + }; + + const deployStub = sinon.stub(deployTools, 'deploy'); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + sinon.stub(verify, 'verifySetup'); + sinon.stub(scJssConfigTool, 'resolveScJssConfig').resolves(scJssConfig); + + await handler(argv); + + expect(deployStub.calledWith(expectedOptions)).to.be.true; + }); + + it('should abort and log error if sitecore dist path is missing from package.json', async () => { + const packageJson = { + config: { + sitecoreConfigPath: 'Santiago', + }, + }; + const deployStub = sinon.stub(deployTools, 'deploy'); + sinon.stub(verify, 'verifySetup'); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + const logSpy = sinon.spy(console, 'error'); + + await handler({}); + + expect(logSpy.getCall(0).args[0].toString()).to.contain( + 'The current project does not support file deployment into the Sitecore instance. You should use an HTTP POST based integration for Experience Editor support. See SDK documentation for details.' + ); + expect(deployStub.called).to.be.false; + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/deploy.items.test.ts b/packages/sitecore-jss-cli/src/scripts/deploy.items.test.ts new file mode 100644 index 0000000000..10a2501d4b --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/deploy.items.test.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { handler } from './deploy.items'; +import * as resolvePkg from '../resolve-package'; +import * as deployTools from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/package-deploy'; +import * as verify from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/setup/verify-setup'; +import * as scJssConfigTool from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/resolve-scjssconfig'; + +describe('deploy.items script', () => { + afterEach(() => { + sinon.restore(); + }); + + const expectedDeployArgs = (argv: any) => ({ + appName: argv.appName, + packagePath: argv.packageOutputPath, + importServiceUrl: argv.deployUrl, + secret: argv.deploySecret, + debugSecurity: argv.debugSecurity, + proxy: argv.proxy, + acceptCertificate: argv.acceptCertificate, + }); + + const packageJson = { + config: { + sitecoreConfigPath: 'Santiago', + sitecoreDistPath: 'C:/SanFrancisco', + appName: 'jss-unit-package', + deploySecret: 'you-are-85%-water', + deployUrl: 'deploy.jss.com', + }, + }; + const scJssConfig = { + sitecore: { + instancePath: 'S:/', + deploySecret: 'you-are-85%-water', + deployUrl: 'deploy.jss.com', + }, + }; + + it('should call deployPackage with parsed deployArgs', async () => { + const argv = { + appName: 'jss-unit', + packageOutputPath: 'mock', + deployUrl: 'customs.jss.com', + deploySecret: 'snape-kills-dumbledore', + debugSecurity: true, + proxy: 'localhost:3000', + acceptCertificate: 'yes', + destination: 'S:/Santiago', + source: 'F:/Biarritz', + exclude: ['this one', 'that one'], + clean: false, + skipPackage: true, + }; + + const deployStub = sinon.stub(deployTools, 'packageDeploy').resolves(); + sinon.stub(verify, 'verifySetup'); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + sinon.stub(scJssConfigTool, 'resolveScJssConfig').resolves(scJssConfig); + + await handler(argv); + + expect(deployStub.calledWith(expectedDeployArgs(argv))).to.be.true; + }); + + it('should use fallback appName, deploySecret, deployUrl when missing', async () => { + const argv = { + packageOutputPath: 'mock', + debugSecurity: true, + proxy: 'localhost:3000', + acceptCertificate: 'yes', + destination: 'S:/Santiago', + source: 'F:/Biarritz', + exclude: ['this one', 'that one'], + clean: false, + skipPackage: true, + }; + + const expectedArgs = { + ...expectedDeployArgs(argv), + appName: packageJson.config.appName, + importServiceUrl: scJssConfig.sitecore.deployUrl, + secret: scJssConfig.sitecore.deploySecret, + }; + + const deployStub = sinon.stub(deployTools, 'packageDeploy').resolves(); + sinon.stub(verify, 'verifySetup'); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + sinon.stub(scJssConfigTool, 'resolveScJssConfig').resolves(scJssConfig); + + await handler(argv); + + expect(deployStub.calledWith(expectedArgs)).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/deploy.template.test.ts b/packages/sitecore-jss-cli/src/scripts/deploy.template.test.ts new file mode 100644 index 0000000000..d940d4d3e5 --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/deploy.template.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as microManifest from '../micro-manifest'; +import { handler } from './deploy.template'; + +describe('deploy.template script', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should parse fields from input', async () => { + const argv = { + fields: ['single', 'multi:multi-line text'], + skipDeploy: true, + }; + const expectedFields = [ + { + name: 'single', + type: 'Single-LineText', + }, + { + name: 'multi', + type: 'multi-linetext', + }, + ]; + const logSpy = sinon.spy(console, 'log'); + + await handler(argv); + + const logOutput = logSpy + .getCall(0) + .args[0].toString() + .replace(/\s/g, ''); + + expect(logOutput).to.contain(JSON.stringify(expectedFields)); + }); + + // the actual work is done in microManifest - so we just test success messages + // and test microManifest separately + it('should log on successful deploy', async () => { + sinon.stub(microManifest, 'default').resolves(); + const logSpy = sinon.spy(console, 'log'); + const argv = { + name: 'unit', + displayName: 'absolute unit', + icon: '', + fields: [], + placeholders: [], + allowedPlaceholders: [], + }; + const successMsg = 'Your template has been created (or updated)!'; + await handler(argv); + + expect(logSpy.getCall(1).args[0].toString()).to.contain(successMsg); + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/deploy.template.ts b/packages/sitecore-jss-cli/src/scripts/deploy.template.ts index 76ca001be8..fe84b624ab 100644 --- a/packages/sitecore-jss-cli/src/scripts/deploy.template.ts +++ b/packages/sitecore-jss-cli/src/scripts/deploy.template.ts @@ -76,7 +76,7 @@ export function args(yargs: Argv) { * @param {Argv} argv */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -async function handler(argv: any) { +export async function handler(argv: any) { // create micro-manifest to deploy from const fields: Array<{ name: string; type: string }> = []; diff --git a/packages/sitecore-jss-cli/src/scripts/elephant.test.ts b/packages/sitecore-jss-cli/src/scripts/elephant.test.ts new file mode 100644 index 0000000000..ae8d05204c --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/elephant.test.ts @@ -0,0 +1,16 @@ +import { expect } from 'chai'; +import { handler } from './elephant'; + +describe('elephant', () => { + it('should not be horsing around', async () => { + expect(await handler()).to.not.be.equal(` + /\/\ + / \ + ~/(o o) + ~/ ) ( + ~/ ( ) + ~/ ~~ + ~/ | + `); + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/environment.test.ts b/packages/sitecore-jss-cli/src/scripts/environment.test.ts new file mode 100644 index 0000000000..295fba0e3d --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/environment.test.ts @@ -0,0 +1,16 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { handler } from './environment'; + +describe('environment script', () => { + it('should print env variable', async () => { + process.env.FOO = 'bar'; + const logSpy = sinon.spy(console, 'log'); + const argv = { + name: 'FOO', + }; + handler(argv); + + expect(logSpy.calledWith(`process.env.${argv.name} = ${process.env.FOO}`)); + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/manifest.test.ts b/packages/sitecore-jss-cli/src/scripts/manifest.test.ts new file mode 100644 index 0000000000..4710d38662 --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/manifest.test.ts @@ -0,0 +1,117 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import readlineSync from 'readline-sync'; +import chalk from 'chalk'; +import { handler } from './manifest'; +import * as resolvePkg from '../resolve-package'; +import * as generate from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/manifest/generator/generate'; +import * as verify from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/setup/verify-setup'; +import fs from 'fs'; + +describe('manifest script', () => { + afterEach(() => { + sinon.restore(); + }); + + const packageJson = { + config: { + sitecoreConfigPath: 'Santiago', + sitecoreDistPath: 'C:/SanFrancisco', + appName: 'jss-unit-package', + deploySecret: 'you-are-85%-water', + deployUrl: 'deploy.jss.com', + rootPlaceholders: ['second-best'], + language: 'da-DK', + }, + }; + + const argv = { + manifestSourceFiles: ['one.js', 'another.js'], + require: 'config.js', + appName: 'jss-manifest', + includeContent: false, + includeDictionary: true, + manifestOutputPath: 'C:/JSS', + debug: false, + rootPlaceholders: ['main', 'top'], + pipelinePatchFiles: ['red-tape.txt'], + wipe: false, + allowConflictingPlaceholderNames: false, + language: 'en', + }; + + const defaultExpectedArgs = { + fileGlobs: argv.manifestSourceFiles, + requireArg: argv.require, + appName: argv.appName, + excludeItems: !argv.includeContent, + excludeMedia: !argv.includeContent, + excludeDictionary: !argv.includeDictionary, + outputPath: `${argv.manifestOutputPath}/sitecore-import.json`, + language: argv.language, + pipelinePatchFileGlobs: argv.pipelinePatchFiles, + debug: argv.debug, + rootPlaceholders: argv.rootPlaceholders, + wipe: argv.wipe, + skipPlaceholderBlacklist: argv.allowConflictingPlaceholderNames, + }; + + it('should invoke file generation with parsed args', async () => { + const generateFileStub = sinon.stub(generate, 'generateToFile').resolves(); + sinon.stub(verify, 'verifySetup'); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + sinon.stub(fs, 'existsSync').returns(false); + + await handler(argv); + + expect(generateFileStub.calledWith(defaultExpectedArgs)).to.be.true; + }); + + it('should use fallaback for appName, language, rootPlaceholders, if not proided', async () => { + const cutArgv = { + ...argv, + appName: undefined, + language: undefined, + rootPlaceholders: undefined, + }; + + const expectedArgs = { + ...defaultExpectedArgs, + appName: packageJson.config.appName, + language: packageJson.config.language, + rootPlaceholders: packageJson.config.rootPlaceholders, + }; + + const generateFileStub = sinon.stub(generate, 'generateToFile').resolves(); + sinon.stub(verify, 'verifySetup'); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + await handler(cutArgv); + + expect(generateFileStub.calledWith(expectedArgs)).to.be.true; + }); + + it('should clarify when wipe is invoked', async () => { + const cutArgv = { + ...argv, + wipe: true, + unattendedWipe: false, + }; + const keyInStub = sinon.stub(readlineSync, 'keyInYN').returns(false); + sinon.stub(process, 'exit'); + + const generateFileStub = sinon.stub(generate, 'generateToFile').resolves(); + sinon.stub(verify, 'verifySetup'); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + await handler(cutArgv); + + expect( + keyInStub.calledWith(chalk.yellow('This will delete any content changes made in Sitecore')) + ).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-cli/src/scripts/package.test.ts b/packages/sitecore-jss-cli/src/scripts/package.test.ts new file mode 100644 index 0000000000..4235bb630d --- /dev/null +++ b/packages/sitecore-jss-cli/src/scripts/package.test.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { handler } from './package'; +import * as resolvePkg from '../resolve-package'; +import * as generate from '@sitecore-jss/sitecore-jss-dev-tools/dist/cjs/package-generate'; + +describe('package script', () => { + afterEach(() => { + sinon.restore(); + }); + + const packageJson = { + config: { + appName: 'jss-unit-package', + }, + }; + + const argv = { + appName: 'jss-manifest', + manifestOutputPath: 'C:/JSS', + packageOutputPath: 'C:/packages', + noItems: true, + skipManifest: true, + }; + + const expectedGenerateArgs = { + appName: argv.appName, + manifestPath: argv.manifestOutputPath, + manifestFileName: 'sitecore-import.json', + outputPath: argv.packageOutputPath, + }; + + it('should invoke package generation with parsed args', async () => { + const generatePkgStub = sinon.stub(generate, 'packageGenerate').resolves(); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + await handler(argv); + + expect(generatePkgStub.calledWith(expectedGenerateArgs)).to.be.true; + }); + + it('should use fallaback for appName if not proided', async () => { + const cutArgv = { + ...argv, + appName: undefined, + }; + + const expectedArgs = { + ...expectedGenerateArgs, + appName: packageJson.config.appName, + }; + const generatePkgStub = sinon.stub(generate, 'packageGenerate').resolves(); + sinon.stub(resolvePkg, 'default').resolves(packageJson); + + await handler(cutArgv); + + expect(generatePkgStub.calledWith(expectedArgs)).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-cli/src/spawn.test.ts b/packages/sitecore-jss-cli/src/spawn.test.ts new file mode 100644 index 0000000000..bcace597a2 --- /dev/null +++ b/packages/sitecore-jss-cli/src/spawn.test.ts @@ -0,0 +1,64 @@ +/* eslint-disable no-unused-expressions */ +import { expect } from 'chai'; +import spawn from 'cross-spawn'; +import sinon from 'sinon'; +import * as scriptSpawn from './spawn'; + +describe('spawn script', () => { + afterEach(() => { + sinon.restore(); + }); + + const defaultSpawnReturn = { + pid: 0, + output: [], + stdout: '', + stderr: '', + status: null, + signal: null, + }; + + it('should log and exit on SIGKILL', () => { + const consoleSpy = sinon.spy(console, 'log'); + const exitStub = sinon.stub(process, 'exit'); + sinon.stub(spawn, 'sync').returns({ + ...defaultSpawnReturn, + signal: 'SIGKILL', + }); + const errorMsg = + 'The operation failed because the process exited too early. ' + + 'This probably means the system ran out of memory or someone called ' + + '`kill -9` on the process.'; + scriptSpawn.default('test', []); + + expect(consoleSpy.calledWith(errorMsg)).to.be.true; + expect(exitStub.calledWith(1)).to.be.true; + }); + + it('should log and exit on SIGTERM', () => { + const consoleSpy = sinon.spy(console, 'log'); + const exitStub = sinon.stub(process, 'exit'); + sinon.stub(spawn, 'sync').returns({ + ...defaultSpawnReturn, + signal: 'SIGTERM', + }); + const errorMsg = + 'The operation failed because the process exited too early. ' + + 'Someone might have called `kill` or `killall`, or the system could ' + + 'be shutting down.'; + scriptSpawn.default('test', []); + expect(consoleSpy.calledWith(errorMsg)).to.be.true; + expect(exitStub.calledWith(1)).to.be.true; + }); + + it('should exit with returned status code, when its not 0', () => { + const exitStatus = 42; + sinon.stub(spawn, 'sync').returns({ + ...defaultSpawnReturn, + status: exitStatus, + }); + const exitStub = sinon.stub(process, 'exit'); + scriptSpawn.default('test', []); + expect(exitStub.calledWith(exitStatus)).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-dev-tools/.nycrc b/packages/sitecore-jss-dev-tools/.nycrc index ffc9b4c393..4dd9fa19b6 100644 --- a/packages/sitecore-jss-dev-tools/.nycrc +++ b/packages/sitecore-jss-dev-tools/.nycrc @@ -8,7 +8,14 @@ "src/**/test-data", "src/pipelines/test", "dist", - "types" + "types", + "./src/manifest/index.ts", + "./src/pipelines/index.ts", + "./src/bin/*.ts", + "./src/manifest/generator/SitecoreIcon.ts", + "./src/manifest/generator/manifest.types*.ts", + "./src/manifest/generator/manifest.babel-shim.ts", + "**/pipeline.config.ts" ], "all": true, "reporter": [ diff --git a/packages/sitecore-jss-dev-tools/package.json b/packages/sitecore-jss-dev-tools/package.json index 7902fcd09f..e1fcec2c18 100644 --- a/packages/sitecore-jss-dev-tools/package.json +++ b/packages/sitecore-jss-dev-tools/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-dev-tools", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "description": "Utilities to assist in the development and deployment of Sitecore JSS apps.", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -33,7 +33,7 @@ "url": "https://github.com/sitecore/jss/issues" }, "dependencies": { - "@sitecore-jss/sitecore-jss": "^21.1.0-canary.57", + "@sitecore-jss/sitecore-jss": "^21.1.0-canary.64", "axios": "^0.21.1", "chalk": "^2.4.1", "chokidar": "^3.5.3", diff --git a/packages/sitecore-jss-dev-tools/src/clean.test.ts b/packages/sitecore-jss-dev-tools/src/clean.test.ts new file mode 100644 index 0000000000..48742f1d61 --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/clean.test.ts @@ -0,0 +1,42 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore + +import del from 'del'; +import { clean } from './clean'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +/* eslint-disable no-unused-expressions */ + +describe('clean', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should clean', () => { + const path = 'your bank account'; + const mockPaths = [ + { + name: 'test', + path: path, + dirent: { + name: 'test', + isBlockDevice: () => false, + isFIFO: () => false, + isCharacterDevice: () => false, + isDirectory: () => true, + isFile: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + }, + }, + ]; + const delStub = sinon.stub(del, 'sync').returns(mockPaths); + + const logSpy = sinon.spy(console, 'log'); + clean({ path: path }); + + expect(delStub.called).to.be.true; + expect(logSpy.calledWith('Cleaned:\n', mockPaths.join('\n'))).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-dev-tools/src/deploy.test.ts b/packages/sitecore-jss-dev-tools/src/deploy.test.ts new file mode 100644 index 0000000000..67067654b4 --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/deploy.test.ts @@ -0,0 +1,97 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import del from 'del'; +import { deploy, DeployOptions } from './deploy'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import fsExtra from 'fs-extra'; +import chalk from 'chalk'; + +/* eslint-disable no-unused-expressions */ + +describe('deploy', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should copy files with correct params', () => { + const logSpy = sinon.spy(console, 'log'); + const options: DeployOptions = { + sourcePath: 'C:/source', + destinationPath: 'D:/target', + }; + sinon.stub(fsExtra, 'existsSync').returns(true); + const fsStub = sinon.stub(fsExtra, 'copySync'); + + deploy(options); + + expect(fsStub.calledWith(options.sourcePath, options.destinationPath, {})).to.be.true; + expect( + logSpy.calledWith(chalk.green('JSS app build artifacts have been deployed to Sitecore.')) + ).to.be.true; + }); + + it('should filter files', () => { + const logSpy = sinon.spy(console, 'log'); + const options: DeployOptions = { + sourcePath: 'C:/source/', + destinationPath: 'D:/target', + }; + sinon.stub(fsExtra, 'existsSync').returns(true); + const fsStub = sinon.stub(fsExtra, 'copySync'); + + deploy(options); + + expect(fsStub.calledWith(options.sourcePath, options.destinationPath, {})).to.be.true; + expect( + logSpy.calledWith(chalk.green('JSS app build artifacts have been deployed to Sitecore.')) + ).to.be.true; + }); + + it('should create destination folder when not present', () => { + const options: DeployOptions = { + sourcePath: 'C:/source/', + destinationPath: 'D:/target', + }; + const logSpy = sinon.spy(console, 'log'); + sinon + .stub(fsExtra, 'existsSync') + .withArgs(options.destinationPath) + .returns(false); + sinon.stub(fsExtra, 'copySync'); + + const createFolderStub = sinon.stub(fsExtra, 'ensureDirSync'); + + deploy(options); + + expect(createFolderStub.calledWith(options.destinationPath)).to.be.true; + expect( + logSpy.calledWith( + `Creating nonexistant destination path ${chalk.green(options.destinationPath)}...` + ) + ).to.be.true; + }); + + it('should clean destination when instructed', () => { + const logSpy = sinon.spy(console, 'log'); + const options: DeployOptions = { + sourcePath: 'C:/source/', + destinationPath: 'D:/target', + clean: true, + }; + sinon.stub(fsExtra, 'existsSync').returns(true); + const delStub = sinon.stub(del, 'sync'); + sinon.stub(fsExtra, 'copySync'); + + deploy(options); + + expect( + delStub.calledWith([`${options.destinationPath}/**`, `!${options.destinationPath}`], { + force: true, + }) + ).to.be.true; + expect( + logSpy.calledWith(`Cleaning existing files from ${chalk.green(options.destinationPath)}...`) + ).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-dev-tools/src/manifest/generator/manifest.test.ts b/packages/sitecore-jss-dev-tools/src/manifest/generator/manifest.test.ts new file mode 100644 index 0000000000..f867f6ced0 --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/manifest/generator/manifest.test.ts @@ -0,0 +1,198 @@ +/* eslint-disable no-unused-expressions */ +import { createManifestInstance } from './manifest'; +import sinon from 'sinon'; +import * as pipelines from '../../pipelines/pipeline'; +import { + CreateManifestInstanceArgs, + TemplateDefinition, + ComponentDefinition, + ItemDefinition, + PlaceholderDefinition, +} from './manifest.types'; +import { expect } from 'chai'; +import { ExecutablePipeline } from '../../pipelines'; +import * as validators from './validators'; + +describe('manifest', () => { + afterEach(() => { + sinon.restore(); + }); + + const generateManifest: ExecutablePipeline = { + name: 'generateManifest', + args: undefined, + processors: function() { + return []; + }, + }; + const args: CreateManifestInstanceArgs = { + pipelines: { + generateManifest: generateManifest, + args: { + extraArg: 'no argument from me', + }, + }, + appName: '', + excludeItems: false, + excludeDictionary: false, + language: '', + debug: false, + wipe: false, + rootPlaceholders: [], + skipPlaceholderBlacklist: false, + }; + + const pipelineResultMock = { + pipelineResult: { + appName: 'unit', + templates: [], + items: { + routes: [], + nonRoutes: [], + }, + placeholders: [], + dictionary: [], + language: '', + wipeExisting: false, + rootPlaceholders: [], + }, + }; + + describe('getManifest', () => { + it('should launch generateManifest pipeline with args', async () => { + const runStub = sinon.stub(pipelines, 'run').resolves(pipelineResultMock); + const { getManifest } = createManifestInstance(args); + + const res = await getManifest(); + const genManifestArgs = runStub.getCall(0).args[0]; + expect(genManifestArgs.name).to.equal('generateManifest'); + + expect(res).to.deep.equal(pipelineResultMock.pipelineResult); + }); + }); + + describe('addTemplate', () => { + it('should add templats to manifestSourceData', async () => { + const runStub = sinon.stub(pipelines, 'run').resolves(pipelineResultMock); + const validatorStub = sinon.stub(validators, 'validateTemplate').returns({ valid: true }); + const templates: TemplateDefinition[] = [ + { + name: 'this is fake', + fields: [], + }, + { + name: 'also fake template', + fields: [], + }, + { + name: 'still fake', + fields: [], + }, + ]; + const { addTemplate, getManifest } = createManifestInstance(args); + addTemplate(...templates); + await getManifest(); + expect(validatorStub.called).to.be.true; + const callArgs = runStub.getCall(0).args[0].args; + expect(callArgs.templates).to.deep.equal(templates); + }); + }); + + describe('addRouteType', () => { + it('should add route type as template to manifestSourceData', async () => { + const runStub = sinon.stub(pipelines, 'run').resolves(pipelineResultMock); + const validatorStub = sinon.stub(validators, 'validateTemplate').returns({ valid: true }); + const templates: TemplateDefinition[] = [ + { + name: 'this is fake', + fields: [], + }, + ]; + const { addRouteType, getManifest } = createManifestInstance(args); + addRouteType(...templates); + await getManifest(); + expect(validatorStub.called).to.be.true; + const callArgs = runStub.getCall(0).args[0].args; + expect(callArgs.templates).to.deep.equal([ + { + ...templates[0], + route: true, + }, + ]); + }); + }); + + describe('addComponent', () => { + it('should add component to manifestSourceData', async () => { + const runStub = sinon.stub(pipelines, 'run').resolves(pipelineResultMock); + const components: ComponentDefinition[] = [ + { + name: 'this is fake', + fields: [], + }, + { + name: 'also fake template', + fields: [], + }, + { + name: 'still fake', + fields: [], + }, + ]; + const { addComponent, getManifest } = createManifestInstance(args); + addComponent(...components); + await getManifest(); + const callArgs = runStub.getCall(0).args[0].args; + expect(callArgs.components).to.deep.equal(components); + }); + }); + + describe('addContent', () => { + it('should add content to manifestSourceData', async () => { + const runStub = sinon.stub(pipelines, 'run').resolves(pipelineResultMock); + const items: ItemDefinition[] = [ + { + name: 'this is fake', + template: 'fake', + }, + { + name: 'also fake template', + template: 'fake', + }, + { + name: 'still fake', + template: 'fake', + }, + ]; + const { addContent, getManifest } = createManifestInstance(args); + addContent(...items); + await getManifest(); + const callArgs = runStub.getCall(0).args[0].args; + expect(callArgs.content).to.deep.equal(items); + }); + }); + + describe('addPlaceholder', () => { + it('should add placeholder to manifestSourceData', async () => { + const runStub = sinon.stub(pipelines, 'run').resolves(pipelineResultMock); + const validatorStub = sinon.stub(validators, 'validatePlaceholder').returns({ valid: true }); + const placeholders: PlaceholderDefinition[] = [ + { + name: 'this is fake', + }, + { + name: 'also fake placeholder', + }, + { + name: 'still fake', + }, + ]; + const { addPlaceholder, getManifest } = createManifestInstance(args); + addPlaceholder(...placeholders); + await getManifest(); + expect(validatorStub.called).to.be.true; + const callArgs = runStub.getCall(0).args[0].args; + expect(callArgs.placeholders).to.deep.equal(placeholders); + }); + }); +}); diff --git a/packages/sitecore-jss-dev-tools/src/manifest/generator/pipelines/generateContentItem/processNestedContent.test.ts b/packages/sitecore-jss-dev-tools/src/manifest/generator/pipelines/generateContentItem/processNestedContent.test.ts new file mode 100644 index 0000000000..001ae189a1 --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/manifest/generator/pipelines/generateContentItem/processNestedContent.test.ts @@ -0,0 +1,79 @@ +/* eslint no-underscore-dangle: "off" */ +/* eslint global-require: "off" */ +/* eslint-disable no-unused-expressions */ + +import { expect } from 'chai'; +import * as pipelines from './../../../../pipelines/pipeline'; +import processNestedContent from './processNestedContent'; +import sinon from 'sinon'; + +describe('generateContentItem pipeline', () => { + describe('processNestedContent processor', () => { + it('should run pipeline for nested content', async () => { + const expected = { + content: { + name: 'item0', + displayName: 'item 0', + template: 'template0', + children: [ + { + name: 'item00', + displayName: 'item 00', + template: 'template0', + children: [ + { + name: 'item000', + displayName: 'item 000', + template: 'template0', + }, + { + name: 'item001', + displayName: 'item 001', + template: 'template0', + }, + ], + }, + ], + }, + } as any; + + const generateContentItem = { + name: 'generateContentItem', + args: undefined, + processors: function() { + return []; + }, + }; + + const pipelineArgs = { + pipelines: { + generateManifest: generateContentItem, + args: { + extraArg: 'no argument from me', + }, + }, + }; + + const args = { + ...expected, + ...pipelineArgs, + }; + + const expectedArgs = { + content: expected.content.children[0], + ...pipelineArgs, + }; + + const runStub = sinon.stub(pipelines, 'run').resolves({ + item: { + name: 'done', + }, + }); + await processNestedContent(args); + + expect(runStub.called).to.be.true; + const callArgs = runStub.getCall(0).args[0]; + expect(callArgs.args).to.deep.equal(expectedArgs); + }); + }); +}); diff --git a/packages/sitecore-jss-dev-tools/src/manifest/generator/traversal.test.ts b/packages/sitecore-jss-dev-tools/src/manifest/generator/traversal.test.ts new file mode 100644 index 0000000000..2ba7f8483e --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/manifest/generator/traversal.test.ts @@ -0,0 +1,172 @@ +/* eslint-disable no-unused-expressions */ +import { + traverseItems, + traverseAllItems, + traverseAllFields, + traverseAllRenderings, +} from './traversal'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +// traverseAllItems, +// traverseAllFields, +// traverseAllRenderings, + +describe('traversal', () => { + afterEach(() => { + sinon.restore(); + }); + + it('traverseItems should recursively process items', () => { + const mockItems = [ + { + name: 'home', + children: [ + { + name: 'sample', + children: [ + { + name: 'child', + }, + ], + }, + ], + }, + ]; + + const cbMock = sinon.stub(); + + traverseItems(mockItems, cbMock); + + expect(cbMock.getCall(0).calledWith(mockItems[0], 'home')).to.be.true; + expect(cbMock.getCall(1).calledWith(mockItems[0].children[0], 'home/sample')).to.be.true; + expect(cbMock.getCall(2).calledWith(mockItems[0].children[0].children[0], 'home/sample/child')) + .to.be.true; + }); + it('traverseAllItems should process items children and related item from fields', () => { + const mockMultiList = [ + { + id: 'GUIDGUID', + name: 'beloved aunt', + }, + { + id: 'GUIDGUID', + name: 'beloved grandpa', + }, + ]; + + const mockFields = [ + { + name: 'oneRelative', + value: { + id: 'GUIDGUID', + name: 'homes long lost uncle', + }, + }, + { + name: 'otherRelatives', + value: mockMultiList, + }, + ]; + const mockRenderings = [ + { + dataSource: { + id: 'GUIDGUID', + name: 'dSource', + }, + }, + ]; + + const mockItems = [ + { + name: 'home', + fields: mockFields, + layout: { + renderings: mockRenderings, + }, + children: [ + { + name: 'sample', + }, + ], + }, + ]; + + const cbStub = sinon.stub(); + + traverseAllItems(mockItems, cbStub); + + expect(cbStub.calledWith(mockItems[0], 'item')).to.be.true; + expect(cbStub.calledWith(mockItems[0].children[0], 'item')).to.be.true; + expect(cbStub.calledWith(mockFields[0].value, 'item')).to.be.true; + expect(cbStub.calledWith(mockMultiList[0], 'item')).to.be.true; + expect(cbStub.calledWith(mockMultiList[1], 'item')).to.be.true; + expect(cbStub.calledWith(mockRenderings[0].dataSource, 'datasource')).to.be.true; + }); + + it('traverseAllFields should use callback for fields', () => { + const mockFields = [ + { + name: 'oneRelative', + value: { + id: 'GUIDGUID', + name: 'homes long lost uncle', + }, + }, + { + name: 'otherRelatives', + value: 'list goes here', + }, + ]; + + const mockItems = [ + { + name: 'home', + fields: mockFields, + }, + ]; + + const cbStub = sinon.stub(); + + traverseAllFields(mockItems, cbStub); + + expect(cbStub.calledWith(mockFields[0])); + expect(cbStub.calledWith(mockFields[1])); + }); + + it('traverseAllRenderings should use callback for renderings', () => { + const mockRenderings = [ + { + dataSource: { + id: 'GUIDGUID', + name: 'dSource', + }, + }, + { + name: 'postrender', + }, + ]; + + const mockItems = [ + { + name: 'home', + fields: [], + layout: { + renderings: mockRenderings, + }, + children: [ + { + name: 'sample', + }, + ], + }, + ]; + + const cbStub = sinon.stub(); + + traverseAllRenderings(mockItems, cbStub); + + expect(cbStub.calledWith(mockRenderings[0], mockItems[0])); + expect(cbStub.calledWith(mockRenderings[1], mockItems[0])); + }); +}); diff --git a/packages/sitecore-jss-dev-tools/src/manifest/manifest-manager.test.ts b/packages/sitecore-jss-dev-tools/src/manifest/manifest-manager.test.ts new file mode 100644 index 0000000000..f3d21f513f --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/manifest/manifest-manager.test.ts @@ -0,0 +1,90 @@ +/* eslint-disable no-unused-expressions */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import chokidar from 'chokidar'; +import { ManifestInstance } from '.'; +import * as generator from './generator/generate'; +import { ManifestManager } from './manifest-manager'; + +describe('ManifestManager', () => { + const mockManifest: ManifestInstance = { + appName: 'unit-test', + templates: [], + items: { + routes: [], + nonRoutes: [], + }, + placeholders: [], + dictionary: [], + language: '', + wipeExisting: false, + rootPlaceholders: [], + }; + const manifestInit = { + rootPath: 'C:/test', + sourceFiles: [ + './sitecore/definitions/**/*.sitecore.js', + './sitecore/definitions/**/*.sitecore.ts', + ], + watchOnlySourceFiles: [], + requireArg: null, + outputPath: './sitecore/manifest/sitecore-import.json', + pipelinePatchFiles: ['./sitecore/pipelines/**/*.patch.js'], + appName: 'JssTestService', + }; + + afterEach(() => { + sinon.restore(); + }); + + describe('setManifestUpdatedCallback', () => { + it('should add callback', async () => { + const mockWatcher = new chokidar.FSWatcher(); + const genStub = sinon.stub(generator, 'generateToVariable').resolves(mockManifest); + const watchStub = sinon.stub(chokidar, 'watch').returns(mockWatcher); + const callbackSpy = sinon.spy(); + const mockEvent = 'a-drill'; + const mockChangedPath = 'C:/changed'; + + const testedManager = new ManifestManager(manifestInit); + testedManager.setManifestUpdatedCallback((manifest) => { + callbackSpy(manifest); + }); + expect(watchStub.called).to.be.true; + mockWatcher.emit('all', mockEvent, mockChangedPath); + + // watcher.emit('all', mockEvent, mockChangedPath); + expect(genStub.called).to.be.true; + const eventHandled = new Promise((resolve) => { + setTimeout(() => { + expect(callbackSpy.called).to.be.true; + resolve(null); + }), + 100; + }); + await eventHandled; + + testedManager.watcher?.close(); + }); + }); + + describe('getManifest', () => { + it('should write to file system on initial manifest', () => { + const genStub = sinon.stub(generator, 'generateToFile').resolves(mockManifest); + const testedManager = new ManifestManager(manifestInit); + testedManager.getManifest('en'); + expect(genStub.called).to.be.true; + }); + + it('should write to memory on consequent manifest', () => { + const genFileStub = sinon.stub(generator, 'generateToFile').resolves(mockManifest); + const genMemStub = sinon.stub(generator, 'generateToVariable').resolves(mockManifest); + const testedManager = new ManifestManager(manifestInit); + testedManager.initialManifest = false; + testedManager.getManifest('en'); + + expect(genFileStub.called).to.be.false; + expect(genMemStub.called).to.be.true; + }); + }); +}); diff --git a/packages/sitecore-jss-dev-tools/src/package-generate.test.ts b/packages/sitecore-jss-dev-tools/src/package-generate.test.ts new file mode 100644 index 0000000000..5990b0626d --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/package-generate.test.ts @@ -0,0 +1,30 @@ +/* eslint-disable no-unused-expressions */ +import * as updateUtils from './update'; +import fsExtra from 'fs-extra'; +import sinon from 'sinon'; +import { packageGenerate } from './package-generate'; +import { expect } from 'chai'; + +describe('package-generate', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should make a call to generate package', async () => { + sinon.stub(fsExtra, 'emptyDirSync'); + sinon.stub(fsExtra, 'copySync'); + + const options = { + appName: 'unit', + outputPath: 'C:/sc/dist/app', + manifestPath: 'C:/myApp/manifest', + manifestFileName: 'mani.fest', + }; + + const createPackageStub = sinon.stub(updateUtils, 'createPackage').callsArg(2); + + await packageGenerate(options); + + expect(createPackageStub.called).to.be.true; + }); +}); diff --git a/packages/sitecore-jss-dev-tools/src/resolve-scjssconfig.test.ts b/packages/sitecore-jss-dev-tools/src/resolve-scjssconfig.test.ts new file mode 100644 index 0000000000..6554673a04 --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/resolve-scjssconfig.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable no-unused-expressions */ +import { resolveScJssConfig } from './resolve-scjssconfig'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +describe('resolve-scjssconfig', () => { + let consoleSpy: sinon.SinonSpy; + + beforeEach(() => { + consoleSpy = sinon.spy(console, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should log and reject when config file not found', async () => { + const mockConfigPath = './src/test-data/scjssconfig-notexists.json'; + const resolveInput = { + configPath: mockConfigPath, + configName: 'sitecore', + assert: true, + }; + + try { + await resolveScJssConfig(resolveInput); + expect(true).to.be.false; // should be not reachable + } catch (err) { + expect(err.toString()).to.equal('config is missing'); + } + expect( + consoleSpy.calledWith( + 'The scjssconfig.json file was missing, and is required. Please set up your connection with `jss setup` and try again.' + ) + ).to.be.true; + }); + + it('should reject when sitecore data not found in config', async () => { + const mockConfigPath = './src/test-data/scjssconfig-empty.json'; + + const resolveInput = { + configPath: mockConfigPath, + configName: 'sitecore', + assert: true, + }; + + try { + await resolveScJssConfig(resolveInput); + console.log('this is fine'); + } catch (err) { + expect(err.toString()).to.equal('config is invalid'); + } + expect( + consoleSpy.calledWith( + `The scjssconfig.json did not contain the ${resolveInput.configName} configuration.` + ) + ).to.be.true; + }); + + it('should return config', async () => { + const mockConfigPath = './src/test-data/scjssconfig-working.json'; + const resolveInput = { + configPath: mockConfigPath, + configName: 'sitecore', + assert: true, + }; + + const mockScJssConfig = require('./test-data/scjssconfig-working.json'); + + const result = await resolveScJssConfig(resolveInput); + + expect(result).to.deep.equal(mockScJssConfig); + expect(consoleSpy.called).to.be.false; + }); +}); diff --git a/packages/sitecore-jss-dev-tools/src/resolve-scjssconfig.ts b/packages/sitecore-jss-dev-tools/src/resolve-scjssconfig.ts index acb689084e..f332903845 100644 --- a/packages/sitecore-jss-dev-tools/src/resolve-scjssconfig.ts +++ b/packages/sitecore-jss-dev-tools/src/resolve-scjssconfig.ts @@ -30,13 +30,13 @@ export function resolveScJssConfig({ 'The scjssconfig.json file was missing, and is required. Please set up your connection with `jss setup` and try again.' ); } - rejectPromise(); + rejectPromise('config is missing'); } else { const json = require(jssConfigJson as string); if (!json[configName]) { console.error(`The scjssconfig.json did not contain the ${configName} configuration.`); - rejectPromise(); + rejectPromise('config is invalid'); } resolvePromise({ diff --git a/packages/sitecore-jss-dev-tools/src/test-data/scjssconfig-empty.json b/packages/sitecore-jss-dev-tools/src/test-data/scjssconfig-empty.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/test-data/scjssconfig-empty.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/sitecore-jss-dev-tools/src/test-data/scjssconfig-working.json b/packages/sitecore-jss-dev-tools/src/test-data/scjssconfig-working.json new file mode 100644 index 0000000000..47c85b563e --- /dev/null +++ b/packages/sitecore-jss-dev-tools/src/test-data/scjssconfig-working.json @@ -0,0 +1,9 @@ +{ + "sitecore": { + "instancePath": "C:\\Work\\Sitecore.JSS\\container\\sxp\\deploy", + "apiKey": "{ADCF0159-031F-4093-9DDA-45FF14C81989}", + "deploySecret": "n5k0vzpto8ip03c1ws8tn3a6tlqr8rqivlyvahb93k9", + "deployUrl": "https://cm.jss.localhost/sitecore/api/jss/import", + "layoutServiceHost": "https://cm.jss.localhost" + } + } \ No newline at end of file diff --git a/packages/sitecore-jss-forms/package.json b/packages/sitecore-jss-forms/package.json index 27a7e6bd02..685e6c12e4 100644 --- a/packages/sitecore-jss-forms/package.json +++ b/packages/sitecore-jss-forms/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-forms", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "sideEffects": false, @@ -44,7 +44,7 @@ "typescript": "~4.3.5" }, "dependencies": { - "@sitecore-jss/sitecore-jss": "^21.1.0-canary.57" + "@sitecore-jss/sitecore-jss": "^21.1.0-canary.64" }, "description": "", "types": "types/index.d.ts", diff --git a/packages/sitecore-jss-nextjs/package.json b/packages/sitecore-jss-nextjs/package.json index 277033e35b..618c7bd5c5 100644 --- a/packages/sitecore-jss-nextjs/package.json +++ b/packages/sitecore-jss-nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-nextjs", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "sideEffects": false, @@ -70,9 +70,9 @@ "react-dom": "^18.2.0" }, "dependencies": { - "@sitecore-jss/sitecore-jss": "^21.1.0-canary.57", - "@sitecore-jss/sitecore-jss-dev-tools": "^21.1.0-canary.57", - "@sitecore-jss/sitecore-jss-react": "^21.1.0-canary.57", + "@sitecore-jss/sitecore-jss": "^21.1.0-canary.64", + "@sitecore-jss/sitecore-jss-dev-tools": "^21.1.0-canary.64", + "@sitecore-jss/sitecore-jss-react": "^21.1.0-canary.64", "node-html-parser": "^6.0.0", "prop-types": "^15.8.1", "regex-parser": "^2.2.11", diff --git a/packages/sitecore-jss-nextjs/src/middleware/index.ts b/packages/sitecore-jss-nextjs/src/middleware/index.ts index cdf0dd6fce..4a0c7b86cd 100644 --- a/packages/sitecore-jss-nextjs/src/middleware/index.ts +++ b/packages/sitecore-jss-nextjs/src/middleware/index.ts @@ -1,2 +1,4 @@ export { RedirectsMiddleware, RedirectsMiddlewareConfig } from './redirects-middleware'; export { PersonalizeMiddleware, PersonalizeMiddlewareConfig } from './personalize-middleware'; +export { MultisiteMiddleware, MultisiteMiddlewareConfig } from './multisite-middleware'; +export { SiteResolver, SiteInfo } from '@sitecore-jss/sitecore-jss/site'; diff --git a/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.test.ts b/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.test.ts new file mode 100644 index 0000000000..9ac9e41dc6 --- /dev/null +++ b/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.test.ts @@ -0,0 +1,348 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable dot-notation */ +import chai, { use } from 'chai'; +import chaiString from 'chai-string'; +import sinonChai from 'sinon-chai'; +import sinon, { spy } from 'sinon'; +import nextjs, { NextRequest, NextResponse } from 'next/server'; +import { debug } from '@sitecore-jss/sitecore-jss'; + +import { MultisiteMiddleware } from './multisite-middleware'; +import { SiteInfo } from '@sitecore-jss/sitecore-jss/site'; + +use(sinonChai); +const expect = chai.use(chaiString).expect; + +describe('MultisiteMiddleware', () => { + const debugSpy = spy(debug, 'multisite'); + const validateDebugLog = (message, ...params) => + expect(debugSpy.args.find((log) => log[0] === message)).to.deep.equal([message, ...params]); + + const siteName = 'foo'; + + const createRequest = (props: any = {}) => { + const req = { + ...props, + nextUrl: { + pathname: '/styleguide', + clone() { + return Object.assign({}, req.nextUrl); + }, + ...props?.nextUrl, + }, + headers: { + get(key: string) { + const headers = { host: 'foo.net', ...props.headerValues }; + return headers[key]; + }, + ...props.headers, + }, + cookies: { + get(cookieName: string) { + const cookies = { ...props.cookieValues }; + return cookies[cookieName]; + }, + ...props?.cookies, + ...props.cookieValues, + }, + } as NextRequest; + + return req; + }; + + const createResponse = (props: any = {}) => { + const res = { + cookies: { + set(key, value) { + res.cookies[key] = value; + }, + }, + headers: {}, + ...props, + } as NextResponse; + + Object.defineProperties(res.headers, { + set: { + value: (key, value) => { + res.headers[key] = value; + }, + enumerable: false, + }, + forEach: { + value: (cb) => { + Object.keys(res.headers).forEach((key) => cb(res.headers[key], key, res.headers)); + }, + enumerable: false, + }, + }); + + return res; + }; + + const createMiddleware = (props = {}) => { + const middleware = new MultisiteMiddleware({ + getSite(hostName) { + return { name: siteName, hostName } as SiteInfo; + }, + ...props, + }); + + const getSite = spy(middleware['config'], 'getSite'); + + return { middleware, getSite }; + }; + + beforeEach(() => { + debugSpy.resetHistory(); + }); + + describe('request skipped', () => { + describe('excluded route', () => { + const res = createResponse(); + + const test = async (pathname: string, middleware) => { + const req = createRequest({ + nextUrl: { + pathname, + }, + }); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('multisite middleware start: %o', { + pathname, + hostname: 'foo.net', + }); + + validateDebugLog('skipped (route excluded)'); + + expect(finalRes).to.deep.equal(res); + + debugSpy.resetHistory(); + }; + + it('default', async () => { + const { middleware } = createMiddleware(); + + await test('/src/image.png', middleware); + await test('/api/layout/render', middleware); + await test('/sitecore/render', middleware); + await test('/_next/webpack', middleware); + }); + + it('should apply both default and custom rules when custom excludeRoute function provided', async () => { + const excludeRoute = (pathname: string) => pathname === '/crazypath/luna'; + + const { middleware } = createMiddleware({ excludeRoute }); + + await test('/src/image.png', middleware); + await test('/api/layout/render', middleware); + await test('/sitecore/render', middleware); + await test('/_next/webpack', middleware); + await test('/crazypath/luna', middleware); + }); + }); + }); + + describe('request passed', () => { + it('fallback hostname is used', async () => { + const req = createRequest({ headerValues: { host: undefined } }); + + const res = createResponse(); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getSite } = createMiddleware({ defaultHostname: 'bar.net' }); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('multisite middleware start: %o', { + pathname: '/styleguide', + hostname: 'bar.net', + }); + + validateDebugLog('host header is missing, default bar.net is used'); + + validateDebugLog('multisite middleware end: %o', { + rewritePath: '/_site_foo/styleguide', + siteName: 'foo', + headers: { + 'x-sc-rewrite': '/_site_foo/styleguide', + }, + cookies: { + ...res.cookies, + sc_site: 'foo', + }, + }); + + expect(getSite).to.be.calledWith('bar.net'); + + expect(finalRes).to.deep.equal(res); + + expect(nextRewriteStub).calledWith({ + ...req.nextUrl, + pathname: '/_site_foo/styleguide', + }); + + nextRewriteStub.restore(); + }); + + it('fallback default hostName is used', async () => { + const req = createRequest({ headerValues: { host: undefined } }); + + const res = createResponse(); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getSite } = createMiddleware(); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('multisite middleware start: %o', { + pathname: '/styleguide', + hostname: 'localhost', + }); + + validateDebugLog('host header is missing, default localhost is used'); + + validateDebugLog('multisite middleware end: %o', { + rewritePath: '/_site_foo/styleguide', + siteName: 'foo', + headers: { + 'x-sc-rewrite': '/_site_foo/styleguide', + }, + cookies: { + ...res.cookies, + sc_site: 'foo', + }, + }); + + expect(getSite).to.be.calledWith('localhost'); + + expect(finalRes).to.deep.equal(res); + + expect(nextRewriteStub).calledWith({ + ...req.nextUrl, + pathname: '/_site_foo/styleguide', + }); + + nextRewriteStub.restore(); + }); + + it('host header is used', async () => { + const req = createRequest(); + + const res = createResponse(); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getSite } = createMiddleware(); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('multisite middleware start: %o', { + pathname: '/styleguide', + hostname: 'foo.net', + }); + + validateDebugLog('multisite middleware end: %o', { + rewritePath: '/_site_foo/styleguide', + siteName: 'foo', + headers: { + 'x-sc-rewrite': '/_site_foo/styleguide', + }, + cookies: { + ...res.cookies, + sc_site: 'foo', + }, + }); + + expect(getSite).to.be.calledWith('foo.net'); + + expect(finalRes).to.deep.equal(res); + + expect(nextRewriteStub).calledWith({ + ...req.nextUrl, + pathname: '/_site_foo/styleguide', + }); + + nextRewriteStub.restore(); + }); + + it('custom response object is not provided', async () => { + const req = createRequest(); + + const res = createResponse(); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getSite } = createMiddleware({}); + + const finalRes = await middleware.getHandler()(req); + + validateDebugLog('multisite middleware start: %o', { + pathname: '/styleguide', + hostname: 'foo.net', + }); + + validateDebugLog('multisite middleware end: %o', { + rewritePath: '/_site_foo/styleguide', + siteName: 'foo', + headers: { + 'x-sc-rewrite': '/_site_foo/styleguide', + }, + cookies: { + ...res.cookies, + sc_site: 'foo', + }, + }); + + expect(getSite).to.be.calledWith('foo.net'); + + expect(finalRes).to.deep.equal(res); + + expect(nextRewriteStub).calledWith({ + ...req.nextUrl, + pathname: '/_site_foo/styleguide', + }); + + nextRewriteStub.restore(); + }); + }); + + describe('error handling', () => { + const req = createRequest(); + const res = createResponse(); + + let errorSpy; + + before(() => { + errorSpy = spy(console, 'log'); + }); + + beforeEach(() => { + errorSpy.resetHistory(); + }); + + after(() => { + errorSpy.restore(); + }); + + it('should handle error', async () => { + const error = new Error('Custom error'); + + const { middleware } = createMiddleware({ + getSite() { + throw error; + }, + }); + + const finalRes = await middleware.getHandler()(req, res); + + expect(errorSpy.getCall(0).calledWith('Multisite middleware failed:')).to.be.true; + expect(errorSpy.getCall(1).calledWith(error)).to.be.true; + + expect(finalRes).to.deep.equal(res); + }); + }); +}); diff --git a/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.ts b/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.ts new file mode 100644 index 0000000000..60d80ea301 --- /dev/null +++ b/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.ts @@ -0,0 +1,125 @@ +import { NextResponse, NextRequest } from 'next/server'; +import { getSiteRewrite, SiteInfo } from '@sitecore-jss/sitecore-jss/site'; +import { debug } from '@sitecore-jss/sitecore-jss'; + +export type MultisiteMiddlewareConfig = { + /** + * Function used to determine if route should be excluded during execution. + * By default, files (pathname.includes('.')), Next.js API routes (pathname.startsWith('/api/')), and Sitecore API routes (pathname.startsWith('/sitecore/')) are ignored. + * This is an important performance consideration since Next.js Edge middleware runs on every request. + * @param {string} pathname The pathname + * @returns {boolean} Whether to exclude the route + */ + excludeRoute?: (pathname: string) => boolean; + /** + * function used to resolve site for given hostname + */ + getSite: (hostname: string) => SiteInfo; + /** + * Fallback hostname in case `host` header is not present + * @default localhost + */ + defaultHostname?: string; +}; + +/** + * Middleware / handler for multisite support + */ +export class MultisiteMiddleware { + private defaultHostname: string; + + /** + * @param {MultisiteMiddlewareConfig} [config] Multisite middleware config + */ + constructor(protected config: MultisiteMiddlewareConfig) { + this.defaultHostname = config.defaultHostname || 'localhost'; + } + + /** + * Gets the Next.js middleware handler with error handling + * @returns middleware handler + */ + public getHandler(): (req: NextRequest, res?: NextResponse) => Promise { + return async (req, res) => { + try { + return await this.handler(req, res); + } catch (error) { + console.log('Multisite middleware failed:'); + console.log(error); + return res || NextResponse.next(); + } + }; + } + + protected excludeRoute(pathname: string) { + if ( + pathname.includes('.') || // Ignore files + pathname.startsWith('/api/') || // Ignore Next.js API calls + pathname.startsWith('/sitecore/') || // Ignore Sitecore API calls + pathname.startsWith('/_next') // Ignore next service calls + ) { + return true; + } + return false; + } + + protected extractDebugHeaders(incomingHeaders: Headers) { + const headers = {} as { [key: string]: string }; + incomingHeaders.forEach((value, key) => (headers[key] = value)); + return headers; + } + + private handler = async (req: NextRequest, res?: NextResponse): Promise => { + const pathname = req.nextUrl.pathname; + const hostHeader = req.headers.get('host')?.split(':')[0]; + const hostname = hostHeader || this.defaultHostname; + + debug.multisite('multisite middleware start: %o', { + pathname, + hostname, + }); + + if (!hostHeader) { + debug.multisite(`host header is missing, default ${hostname} is used`); + } + + // Response will be provided if other middleware is run before us + let response = res || NextResponse.next(); + + if ( + this.excludeRoute(pathname) || + (this.config.excludeRoute && this.config.excludeRoute(pathname)) + ) { + debug.multisite('skipped (route excluded)'); + return response; + } + + const { name: siteName } = this.config.getSite(hostname); + + // Rewrite to site specific path + const rewritePath = getSiteRewrite(pathname, { + siteName, + }); + + // Note an absolute URL is required: https://nextjs.org/docs/messages/middleware-relative-urls + const rewriteUrl = req.nextUrl.clone(); + + rewriteUrl.pathname = rewritePath; + + response = NextResponse.rewrite(rewriteUrl); + + // Share site name with the following executed middlewares + response.cookies.set('sc_site', siteName); + // Share rewrite path with following executed middlewares + response.headers.set('x-sc-rewrite', rewritePath); + + debug.multisite('multisite middleware end: %o', { + rewritePath, + siteName, + headers: this.extractDebugHeaders(response.headers), + cookies: response.cookies, + }); + + return response; + }; +} diff --git a/packages/sitecore-jss-nextjs/src/middleware/personalize-middleware.test.ts b/packages/sitecore-jss-nextjs/src/middleware/personalize-middleware.test.ts index 5e716ad6e0..59025fe1b0 100644 --- a/packages/sitecore-jss-nextjs/src/middleware/personalize-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/middleware/personalize-middleware.test.ts @@ -20,6 +20,9 @@ describe('PersonalizeMiddleware', () => { const validateDebugLog = (message, ...params) => expect(debugSpy.args.find((log) => log[0] === message)).to.deep.equal([message, ...params]); + const hostname = 'foo.net'; + const siteName = 'bar'; + const id = 'item-id'; const version = '1'; const variantIds = ['variant-1', 'variant-2']; @@ -66,7 +69,14 @@ describe('PersonalizeMiddleware', () => { return cookies[cookieName]; }, - ...props?.cookies, + ...props.cookies, + }, + headers: { + host: hostname, + get(key: string) { + return req.headers[key]; + }, + ...props.headerValues, }, referrer, } as NextRequest; @@ -80,8 +90,18 @@ describe('PersonalizeMiddleware', () => { set(key, value) { res.cookies[key] = value; }, + get(key) { + return res.cookies[key]; + }, + ...props.cookieValues, + }, + headers: { + host: hostname, + get(key: string) { + return res.headers[key]; + }, + ...props.headerValues, }, - headers: {}, ...props, } as NextResponse; @@ -128,11 +148,14 @@ describe('PersonalizeMiddleware', () => { const edgeConfig = { apiKey: 'edge-api-key', endpoint: 'http://edge-endpoint/api/graph/edge', - siteName: 'nextjs-app', ...(props?.edgeConfig || {}), }; + const getSite: any = + props.getSite || sinon.stub().returns({ name: siteName, language: '', hostName: hostname }); + const middleware = new PersonalizeMiddleware({ + getSite, ...props, cdpConfig, edgeConfig, @@ -160,7 +183,7 @@ describe('PersonalizeMiddleware', () => { ) )); - return { middleware, executeExperience, generateBrowserId, getPersonalizeInfo }; + return { middleware, executeExperience, generateBrowserId, getPersonalizeInfo, getSite }; }; beforeEach(() => { @@ -512,7 +535,7 @@ describe('PersonalizeMiddleware', () => { const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); - const { middleware, getPersonalizeInfo, executeExperience } = createMiddleware({ + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ variantId: 'variant-2', }); @@ -534,10 +557,14 @@ describe('PersonalizeMiddleware', () => { rewritePath: '/_variantId_variant-2/styleguide', browserId: 'browser-id', headers: { + ...res.headers, 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', }, }); + expect(getSite).to.be.calledWith(hostname); + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; expect(finalRes).to.deep.equal(res); @@ -564,6 +591,7 @@ describe('PersonalizeMiddleware', () => { generateBrowserId, getPersonalizeInfo, executeExperience, + getSite, } = createMiddleware({ browserId, variantId: 'variant-2', @@ -589,10 +617,14 @@ describe('PersonalizeMiddleware', () => { rewritePath: '/_variantId_variant-2/styleguide', browserId: 'browser-id', headers: { + ...res.headers, 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', }, }); + expect(getSite).to.be.calledWith(hostname); + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; expect(finalRes).to.deep.equal(res); @@ -617,7 +649,7 @@ describe('PersonalizeMiddleware', () => { const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); - const { middleware, getPersonalizeInfo, executeExperience } = createMiddleware({ + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ variantId: 'variant-2', personalizeInfo: { variantIds, @@ -643,10 +675,14 @@ describe('PersonalizeMiddleware', () => { rewritePath: '/_variantId_variant-2/styleguide', browserId: 'browser-id', headers: { + ...res.headers, 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', }, }); + expect(getSite).to.be.calledWith(hostname); + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; expect(finalRes).to.deep.equal(res); @@ -669,7 +705,7 @@ describe('PersonalizeMiddleware', () => { const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); - const { middleware, getPersonalizeInfo, executeExperience } = createMiddleware({ + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ variantId: 'variant-2', }); @@ -691,10 +727,14 @@ describe('PersonalizeMiddleware', () => { rewritePath: '/_variantId_variant-2/styleguide', browserId: 'browser-id', headers: { + ...res.headers, 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', }, }); + expect(getSite).to.be.calledWith(hostname); + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; expect(finalRes).to.deep.equal(res); @@ -712,7 +752,7 @@ describe('PersonalizeMiddleware', () => { const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); - const { middleware, getPersonalizeInfo, executeExperience } = createMiddleware({ + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ variantId: 'variant-2', }); @@ -734,10 +774,14 @@ describe('PersonalizeMiddleware', () => { rewritePath: '/_variantId_variant-2/styleguide', browserId: 'browser-id', headers: { + ...res.headers, 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', }, }); + expect(getSite).to.be.calledWith(hostname); + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; expect(finalRes).to.deep.equal(res); @@ -757,7 +801,7 @@ describe('PersonalizeMiddleware', () => { const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); - const { middleware, getPersonalizeInfo, executeExperience } = createMiddleware({ + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ variantId: 'variant-2', }); @@ -788,10 +832,220 @@ describe('PersonalizeMiddleware', () => { rewritePath: '/_variantId_variant-2/styleguide', browserId: 'browser-id', headers: { + ...res.headers, 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', }, }); + expect(getSite).to.be.calledWith(hostname); + + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; + + expect(finalRes).to.deep.equal(res); + + expect(finalRes.cookies['BID_cdp-client-key']).to.equal(browserId); + + getCookiesSpy.restore(); + nextRewriteStub.restore(); + }); + + it('sc_site cookie is provided', async () => { + const req = createRequest(); + const res = createResponse({ + cookieValues: { + 'BID_cdp-client-key': 'browser-id', + sc_site: 'foo', + }, + }); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ + variantId: 'variant-2', + }); + + const getCookiesSpy = spy(req.cookies, 'get'); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('personalize middleware start: %o', { + pathname: '/styleguide', + language: 'en', + }); + + expect(getPersonalizeInfo.calledWith('/styleguide', 'en', 'foo')).to.be.true; + + expect(executeExperience.calledWith(contentId, browserId, ua, pointOfSale, experienceParams)) + .to.be.true; + + validateDebugLog('personalize middleware end: %o', { + rewritePath: '/_variantId_variant-2/styleguide', + browserId: 'browser-id', + headers: { + ...res.headers, + 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', + }, + }); + + expect(getSite).not.called.to.equal(true); + + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; + + expect(finalRes).to.deep.equal(res); + + expect(finalRes.cookies['BID_cdp-client-key']).to.equal(browserId); + + getCookiesSpy.restore(); + nextRewriteStub.restore(); + }); + + it('x-sc-rewrite header is provided', async () => { + const req = createRequest(); + const res = createResponse({ + headerValues: { + 'x-sc-rewrite': '/_site_nextjs-app/styleguide', + }, + }); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ + variantId: 'variant-2', + }); + + const getCookiesSpy = spy(req.cookies, 'get'); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('personalize middleware start: %o', { + pathname: '/styleguide', + language: 'en', + }); + + expect(getPersonalizeInfo.calledWith('/styleguide', 'en', siteName)).to.be.true; + + expect(executeExperience.calledWith(contentId, browserId, ua, pointOfSale, experienceParams)) + .to.be.true; + + validateDebugLog('personalize middleware end: %o', { + rewritePath: '/_variantId_variant-2/_site_nextjs-app/styleguide', + browserId: 'browser-id', + headers: { + ...res.headers, + 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/_site_nextjs-app/styleguide', + }, + }); + + expect(getSite).to.be.calledWith(hostname); + + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; + + expect(finalRes).to.deep.equal(res); + + expect(finalRes.cookies['BID_cdp-client-key']).to.equal(browserId); + + getCookiesSpy.restore(); + nextRewriteStub.restore(); + }); + + it('default fallback hostname is used', async () => { + const req = createRequest({ + headerValues: { + host: undefined, + }, + }); + const res = createResponse(); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ + variantId: 'variant-2', + }); + + const getCookiesSpy = spy(req.cookies, 'get'); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('personalize middleware start: %o', { + pathname: '/styleguide', + language: 'en', + }); + + validateDebugLog('host header is missing, default localhost is used'); + + expect(getPersonalizeInfo.calledWith('/styleguide', 'en', siteName)).to.be.true; + + expect(executeExperience.calledWith(contentId, browserId, ua, pointOfSale, experienceParams)) + .to.be.true; + + validateDebugLog('personalize middleware end: %o', { + rewritePath: '/_variantId_variant-2/styleguide', + browserId: 'browser-id', + headers: { + ...res.headers, + 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', + }, + }); + + expect(getSite).to.be.calledWith('localhost'); + + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; + + expect(finalRes).to.deep.equal(res); + + expect(finalRes.cookies['BID_cdp-client-key']).to.equal(browserId); + + getCookiesSpy.restore(); + nextRewriteStub.restore(); + }); + + it('custom fallback hostname is used', async () => { + const req = createRequest({ + headerValues: { + host: undefined, + }, + }); + const res = createResponse(); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getPersonalizeInfo, executeExperience, getSite } = createMiddleware({ + variantId: 'variant-2', + defaultHostname: 'foobar', + }); + + const getCookiesSpy = spy(req.cookies, 'get'); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('personalize middleware start: %o', { + pathname: '/styleguide', + language: 'en', + }); + + validateDebugLog('host header is missing, default foobar is used'); + + expect(getPersonalizeInfo.calledWith('/styleguide', 'en', siteName)).to.be.true; + + expect(executeExperience.calledWith(contentId, browserId, ua, pointOfSale, experienceParams)) + .to.be.true; + + validateDebugLog('personalize middleware end: %o', { + rewritePath: '/_variantId_variant-2/styleguide', + browserId: 'browser-id', + headers: { + ...res.headers, + 'x-middleware-cache': 'no-cache', + 'x-sc-rewrite': '/_variantId_variant-2/styleguide', + }, + }); + + expect(getSite).to.be.calledWith('foobar'); + expect(getCookiesSpy.calledWith('BID_cdp-client-key')).to.be.true; expect(finalRes).to.deep.equal(res); @@ -809,7 +1063,11 @@ describe('PersonalizeMiddleware', () => { body: '
Regular page
', }); - const errorSpy = spy(console, 'log'); + let errorSpy; + + before(() => { + errorSpy = spy(console, 'log'); + }); beforeEach(() => { errorSpy.resetHistory(); diff --git a/packages/sitecore-jss-nextjs/src/middleware/personalize-middleware.ts b/packages/sitecore-jss-nextjs/src/middleware/personalize-middleware.ts index dc0b550220..4e4f8f415e 100644 --- a/packages/sitecore-jss-nextjs/src/middleware/personalize-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/middleware/personalize-middleware.ts @@ -7,6 +7,7 @@ import { ExperienceParams, getPersonalizedRewrite, } from '@sitecore-jss/sitecore-jss/personalize'; +import { SiteInfo } from '@sitecore-jss/sitecore-jss/site'; import { debug, NativeDataFetcher } from '@sitecore-jss/sitecore-jss'; export type PersonalizeMiddlewareConfig = { @@ -38,6 +39,15 @@ export type PersonalizeMiddlewareConfig = { * @returns {boolean} false by default */ disabled?: (req?: NextRequest, res?: NextResponse) => boolean; + /** + * function used to resolve site for given hostname + */ + getSite: (hostname: string) => SiteInfo; + /** + * fallback hostname in case `host` header is not present + * @default localhost + */ + defaultHostname?: string; }; /** @@ -46,6 +56,7 @@ export type PersonalizeMiddlewareConfig = { export class PersonalizeMiddleware { private personalizeService: GraphQLPersonalizeService; private cdpService: CdpService; + private defaultHostname: string; /** * @param {PersonalizeMiddlewareConfig} [config] Personalize middleware config @@ -75,6 +86,8 @@ export class PersonalizeMiddleware { return (url: string, data?: unknown) => fetcher.fetch(url, data); }, }); + + this.defaultHostname = config.defaultHostname || 'localhost'; } /** @@ -149,8 +162,11 @@ export class PersonalizeMiddleware { } private handler = async (req: NextRequest, res?: NextResponse): Promise => { + const hostHeader = req.headers.get('host')?.split(':')[0]; + const hostname = hostHeader || this.defaultHostname; const pathname = req.nextUrl.pathname; const language = req.nextUrl.locale || req.nextUrl.defaultLocale || 'en'; + const siteName = res?.cookies.get('sc_site') || this.config.getSite(hostname).name; let browserId = this.getBrowserId(req); debug.personalize('personalize middleware start: %o', { @@ -158,6 +174,10 @@ export class PersonalizeMiddleware { language, }); + if (!hostHeader) { + debug.personalize(`host header is missing, default ${hostname} is used`); + } + // Response will be provided if other middleware is run before us (e.g. redirects) let response = res || NextResponse.next(); @@ -180,7 +200,11 @@ export class PersonalizeMiddleware { } // Get personalization info from Experience Edge - const personalizeInfo = await this.personalizeService.getPersonalizeInfo(pathname, language); + const personalizeInfo = await this.personalizeService.getPersonalizeInfo( + pathname, + language, + siteName + ); if (!personalizeInfo) { // Likely an invalid route / language @@ -224,8 +248,11 @@ export class PersonalizeMiddleware { return response; } + // Path can be rewritten by previously executed middleware + const basePath = res?.headers.get('x-sc-rewrite') || pathname; + // Rewrite to persononalized path - const rewritePath = getPersonalizedRewrite(pathname, { variantId }); + const rewritePath = getPersonalizedRewrite(basePath, { variantId }); // Note an absolute URL is required: https://nextjs.org/docs/messages/middleware-relative-urls const rewriteUrl = req.nextUrl.clone(); rewriteUrl.pathname = rewritePath; @@ -234,9 +261,13 @@ export class PersonalizeMiddleware { // Disable preflight caching to force revalidation on client-side navigation (personalization may be influenced) // See https://github.com/vercel/next.js/issues/32727 response.headers.set('x-middleware-cache', 'no-cache'); + // Share rewrite path with following executed middlewares + response.headers.set('x-sc-rewrite', rewritePath); // Set browserId cookie on the response this.setBrowserId(response, browserId); + // Share site name with the following executed middlewares + response.cookies.set('sc_site', siteName); debug.personalize('personalize middleware end: %o', { rewritePath, diff --git a/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.test.ts b/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.test.ts index e1469ca4b9..bc21513b9e 100644 --- a/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.test.ts @@ -12,6 +12,8 @@ const expect = chai.use(chaiString).expect; describe('RedirectsMiddleware', () => { const referrer = 'http://localhost:3000'; + const hostname = 'foo.net'; + const siteName = 'nextjs-app'; const createRequest = (props: any = {}) => { const req = { @@ -24,6 +26,19 @@ describe('RedirectsMiddleware', () => { }, ...props?.nextUrl, }, + cookies: { + get(key: string) { + return req.cookies[key]; + }, + ...props.cookies, + }, + headers: { + host: hostname, + get(key: string) { + return req.headers[key]; + }, + ...props.headerValues, + }, referrer, } as NextRequest; @@ -39,13 +54,17 @@ describe('RedirectsMiddleware', () => { isQueryStringPreserved?: boolean; locale?: string; fetchRedirectsStub?: sinon.SinonStub; + defaultHostname?: string; } = {} ) => { + const getSite: any = + props.getSite || sinon.stub().returns({ name: siteName, language: '', hostName: hostname }); + const middleware = new RedirectsMiddleware({ + getSite, ...props, apiKey: 'edge-api-key', endpoint: 'http://edge-endpoint/api/graph/edge', - siteName: 'nextjs-app', locales: ['en', 'ua'], }); @@ -63,7 +82,7 @@ describe('RedirectsMiddleware', () => { ) )); - return { middleware, fetchRedirects }; + return { middleware, fetchRedirects, getSite }; }; afterEach(() => { @@ -122,8 +141,10 @@ describe('RedirectsMiddleware', () => { it('should return next response when redirects does not exist', async () => { const res = NextResponse.next(); const req = createRequest(); - const { middleware, fetchRedirects } = createMiddleware(); + const { middleware, fetchRedirects, getSite } = createMiddleware(); const finalRes = await middleware.getHandler()(req); + + expect(getSite).to.be.calledWith(hostname); // eslint-disable-next-line no-unused-expressions expect(fetchRedirects.called).to.be.true; expect(finalRes).to.deep.equal(res); @@ -142,7 +163,7 @@ describe('RedirectsMiddleware', () => { }, }); - const { middleware, fetchRedirects } = createMiddleware({ + const { middleware, fetchRedirects, getSite } = createMiddleware({ pattern: 'not-found', target: 'http://localhost:3000/found', redirectType: 'REDIRECT_301', @@ -152,6 +173,7 @@ describe('RedirectsMiddleware', () => { const finalRes = await middleware.getHandler()(req); + expect(getSite).to.be.calledWith(hostname); // eslint-disable-next-line no-unused-expressions expect(fetchRedirects.called).to.be.true; expect(finalRes).to.deep.equal(res); @@ -170,7 +192,7 @@ describe('RedirectsMiddleware', () => { }, }); - const { middleware, fetchRedirects } = createMiddleware({ + const { middleware, fetchRedirects, getSite } = createMiddleware({ pattern: 'not-found', target: '/ua/found', redirectType: 'REDIRECT_TYPE_SERVER_TRANSFER', @@ -180,6 +202,7 @@ describe('RedirectsMiddleware', () => { const finalRes = await middleware.getHandler()(req); + expect(getSite).to.be.calledWith(hostname); // eslint-disable-next-line no-unused-expressions expect(fetchRedirects.called).to.be.true; expect(finalRes).to.deep.equal(res); @@ -198,7 +221,7 @@ describe('RedirectsMiddleware', () => { }, }); - const { middleware, fetchRedirects } = createMiddleware({ + const { middleware, fetchRedirects, getSite } = createMiddleware({ pattern: 'not-found', target: '/found', redirectType: 'REDIRECT_TYPE_SERVER_TRANSFER', @@ -207,6 +230,7 @@ describe('RedirectsMiddleware', () => { const finalRes = await middleware.getHandler()(req); + expect(getSite).to.be.calledWith(hostname); // eslint-disable-next-line no-unused-expressions expect(fetchRedirects.called).to.be.true; expect(finalRes).to.deep.equal(res); @@ -225,7 +249,7 @@ describe('RedirectsMiddleware', () => { }, }); - const { middleware, fetchRedirects } = createMiddleware({ + const { middleware, fetchRedirects, getSite } = createMiddleware({ pattern: 'not-found', target: 'http://localhost:3000/found', redirectType: 'REDIRECT_302', @@ -235,6 +259,7 @@ describe('RedirectsMiddleware', () => { const finalRes = await middleware.getHandler()(req); + expect(getSite).to.be.calledWith(hostname); // eslint-disable-next-line no-unused-expressions expect(fetchRedirects.called).to.be.true; expect(finalRes).to.deep.equal(res); @@ -253,7 +278,7 @@ describe('RedirectsMiddleware', () => { }, }); - const { middleware, fetchRedirects } = createMiddleware({ + const { middleware, fetchRedirects, getSite } = createMiddleware({ pattern: 'not-found', target: 'http://localhost:3000/found', redirectType: 'default', @@ -263,6 +288,7 @@ describe('RedirectsMiddleware', () => { const finalRes = await middleware.getHandler()(req); + expect(getSite).to.be.calledWith(hostname); // eslint-disable-next-line no-unused-expressions expect(fetchRedirects.called).to.be.true; expect(finalRes).to.deep.equal(res); @@ -280,7 +306,7 @@ describe('RedirectsMiddleware', () => { }, }); - const { middleware, fetchRedirects } = createMiddleware({ + const { middleware, fetchRedirects, getSite } = createMiddleware({ pattern: 'not-found', target: 'http://localhost:3000/found', redirectType: REDIRECT_TYPE_SERVER_TRANSFER, @@ -290,10 +316,104 @@ describe('RedirectsMiddleware', () => { const finalRes = await middleware.getHandler()(req); + expect(getSite).to.be.calledWith(hostname); // eslint-disable-next-line no-unused-expressions expect(fetchRedirects.called).to.be.true; expect(finalRes).to.deep.equal(res); }); + + it('should use sc_site cookie', async () => { + const siteName = 'foo'; + const res = NextResponse.redirect('http://localhost:3000/found', 301); + res.cookies.set('sc_site', siteName); + const req = createRequest({ + nextUrl: { + pathname: '/not-found', + locale: 'en', + clone() { + return Object.assign({}, req.nextUrl); + }, + }, + }); + + const { middleware, fetchRedirects, getSite } = createMiddleware({ + pattern: 'not-found', + target: 'http://localhost:3000/found', + redirectType: 'REDIRECT_301', + isQueryStringPreserved: true, + locale: 'en', + }); + + const finalRes = await middleware.getHandler()(req, res); + + expect(getSite).not.called.to.equal(true); + expect(fetchRedirects).to.be.calledWith(siteName); + expect(finalRes).to.deep.equal(res); + expect(finalRes.status).to.equal(res.status); + }); + + it('default fallback hostname is used', async () => { + const res = NextResponse.redirect('http://localhost:3000/found', 301); + const req = createRequest({ + headerValues: { + host: undefined, + }, + nextUrl: { + pathname: '/not-found', + locale: 'en', + clone() { + return Object.assign({}, req.nextUrl); + }, + }, + }); + + const { middleware, fetchRedirects, getSite } = createMiddleware({ + pattern: 'not-found', + target: 'http://localhost:3000/found', + redirectType: 'REDIRECT_301', + isQueryStringPreserved: true, + locale: 'en', + }); + + const finalRes = await middleware.getHandler()(req); + + expect(getSite).to.be.calledWith('localhost'); + expect(fetchRedirects).to.be.calledWith(siteName); + expect(finalRes).to.deep.equal(res); + expect(finalRes.status).to.equal(res.status); + }); + + it('custom fallback hostname is used', async () => { + const res = NextResponse.redirect('http://localhost:3000/found', 301); + const req = createRequest({ + headerValues: { + host: undefined, + }, + nextUrl: { + pathname: '/not-found', + locale: 'en', + clone() { + return Object.assign({}, req.nextUrl); + }, + }, + }); + + const { middleware, fetchRedirects, getSite } = createMiddleware({ + pattern: 'not-found', + target: 'http://localhost:3000/found', + redirectType: 'REDIRECT_301', + isQueryStringPreserved: true, + locale: 'en', + defaultHostname: 'foobar', + }); + + const finalRes = await middleware.getHandler()(req); + + expect(getSite).to.be.calledWith('foobar'); + expect(fetchRedirects).to.be.calledWith(siteName); + expect(finalRes).to.deep.equal(res); + expect(finalRes.status).to.equal(res.status); + }); }); }); }); diff --git a/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.ts b/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.ts index d7959589ed..e36eddcb5f 100644 --- a/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.ts @@ -7,6 +7,7 @@ import { REDIRECT_TYPE_301, REDIRECT_TYPE_302, REDIRECT_TYPE_SERVER_TRANSFER, + SiteInfo, } from '@sitecore-jss/sitecore-jss/site'; /** @@ -29,6 +30,15 @@ export type RedirectsMiddlewareConfig = Omit boolean; + /** + * function used to resolve site for given hostname + */ + getSite: (hostname: string) => SiteInfo; + /** + * fallback hostname in case `host` header is not present + * @default localhost + */ + defaultHostname?: string; }; /** * Middleware / handler fetches all redirects from Sitecore instance by grapqhl service @@ -37,6 +47,7 @@ export type RedirectsMiddlewareConfig = Omit Promise { + public getHandler(): (req: NextRequest, res?: NextResponse) => Promise { return this.handler; } @@ -68,60 +80,81 @@ export class RedirectsMiddleware { return false; } - private handler = async (req: NextRequest): Promise => { - if ( - (this.config.disabled && this.config.disabled(req, NextResponse.next())) || - this.excludeRoute(req.nextUrl.pathname) || - (this.config.excludeRoute && this.config.excludeRoute(req.nextUrl.pathname)) - ) { - return NextResponse.next(); - } - // Find the redirect from result of RedirectService - const existsRedirect = await this.getExistsRedirect(req); + protected getHostname(req: NextRequest) { + const hostHeader = req.headers.get('host')?.split(':')[0]; + return hostHeader || this.defaultHostname; + } - if (!existsRedirect) { - return NextResponse.next(); - } + private handler = async (req: NextRequest, res?: NextResponse): Promise => { + const hostname = this.getHostname(req); + const siteName = res?.cookies.get('sc_site') || this.config.getSite(hostname).name; - const url = req.nextUrl.clone(); - const absoluteUrlRegex = new RegExp('^(?:[a-z]+:)?//', 'i'); - if (absoluteUrlRegex.test(existsRedirect.target)) { - url.href = existsRedirect.target; - url.locale = req.nextUrl.locale; - } else { - url.search = existsRedirect.isQueryStringPreserved ? url.search : ''; - const urlFirstPart = existsRedirect.target.split('/')[1]; - if (this.locales.includes(urlFirstPart)) { - url.locale = urlFirstPart; - url.pathname = existsRedirect.target.replace(`/${urlFirstPart}`, ''); - } else { - url.pathname = existsRedirect.target; + const createResponse = async () => { + if ( + (this.config.disabled && this.config.disabled(req, NextResponse.next())) || + this.excludeRoute(req.nextUrl.pathname) || + (this.config.excludeRoute && this.config.excludeRoute(req.nextUrl.pathname)) + ) { + return NextResponse.next(); } - } + // Find the redirect from result of RedirectService + const existsRedirect = await this.getExistsRedirect(req, siteName); - const redirectUrl = decodeURIComponent(url.href); - - /** return Response redirect with http code of redirect type **/ - switch (existsRedirect.redirectType) { - case REDIRECT_TYPE_301: - return NextResponse.redirect(redirectUrl, 301); - case REDIRECT_TYPE_302: - return NextResponse.redirect(redirectUrl, 302); - case REDIRECT_TYPE_SERVER_TRANSFER: - return NextResponse.rewrite(redirectUrl); - default: + if (!existsRedirect) { return NextResponse.next(); - } + } + + const url = req.nextUrl.clone(); + const absoluteUrlRegex = new RegExp('^(?:[a-z]+:)?//', 'i'); + if (absoluteUrlRegex.test(existsRedirect.target)) { + url.href = existsRedirect.target; + url.locale = req.nextUrl.locale; + } else { + url.search = existsRedirect.isQueryStringPreserved ? url.search : ''; + const urlFirstPart = existsRedirect.target.split('/')[1]; + if (this.locales.includes(urlFirstPart)) { + url.locale = urlFirstPart; + url.pathname = existsRedirect.target.replace(`/${urlFirstPart}`, ''); + } else { + url.pathname = existsRedirect.target; + } + } + + const redirectUrl = decodeURIComponent(url.href); + + /** return Response redirect with http code of redirect type **/ + switch (existsRedirect.redirectType) { + case REDIRECT_TYPE_301: + return NextResponse.redirect(redirectUrl, 301); + case REDIRECT_TYPE_302: + return NextResponse.redirect(redirectUrl, 302); + case REDIRECT_TYPE_SERVER_TRANSFER: + return NextResponse.rewrite(redirectUrl); + default: + return NextResponse.next(); + } + }; + + const response = await createResponse(); + + // Share site name with the following executed middlewares + response.cookies.set('sc_site', siteName); + + return response; }; /** * Method returns RedirectInfo when matches - * @param {NextRequest} req + * @param {NextRequest} req request + * @param {string} siteName site name * @returns Promise * @private */ - private async getExistsRedirect(req: NextRequest): Promise { - const redirects = await this.redirectsService.fetchRedirects(); + private async getExistsRedirect( + req: NextRequest, + siteName: string + ): Promise { + const redirects = await this.redirectsService.fetchRedirects(siteName); return redirects.length ? redirects.find((redirect: RedirectInfo) => { diff --git a/packages/sitecore-jss-proxy/package.json b/packages/sitecore-jss-proxy/package.json index 920187128b..5e75c82224 100644 --- a/packages/sitecore-jss-proxy/package.json +++ b/packages/sitecore-jss-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-proxy", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "description": "Proxy middleware for express.js server.", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/sitecore-jss-react-forms/package.json b/packages/sitecore-jss-react-forms/package.json index fd69d91b6e..9e0228b3f9 100644 --- a/packages/sitecore-jss-react-forms/package.json +++ b/packages/sitecore-jss-react-forms/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-react-forms", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "sideEffects": false, @@ -55,7 +55,7 @@ "react-dom": "^18.2.0" }, "dependencies": { - "@sitecore-jss/sitecore-jss-forms": "^21.1.0-canary.57", + "@sitecore-jss/sitecore-jss-forms": "^21.1.0-canary.64", "prop-types": "^15.8.1" }, "description": "", diff --git a/packages/sitecore-jss-react-native/package.json b/packages/sitecore-jss-react-native/package.json index 1cecbbfbbe..f199fc4883 100644 --- a/packages/sitecore-jss-react-native/package.json +++ b/packages/sitecore-jss-react-native/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-react-native", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "description": "", "main": "dist/index.js", "scripts": { @@ -28,7 +28,7 @@ "url": "https://github.com/sitecore/jss/issues" }, "dependencies": { - "@sitecore-jss/sitecore-jss": "^21.1.0-canary.57", + "@sitecore-jss/sitecore-jss": "^21.1.0-canary.64", "prop-types": "^15.7.2", "react-native-htmlview": "^0.15.0", "react-native-svg": "^5.3.0", diff --git a/packages/sitecore-jss-react/package.json b/packages/sitecore-jss-react/package.json index 4df07e851e..6842b75498 100644 --- a/packages/sitecore-jss-react/package.json +++ b/packages/sitecore-jss-react/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-react", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "sideEffects": false, @@ -61,7 +61,7 @@ "react-dom": "^18.2.0" }, "dependencies": { - "@sitecore-jss/sitecore-jss": "^21.1.0-canary.57", + "@sitecore-jss/sitecore-jss": "^21.1.0-canary.64", "deep-equal": "^2.1.0", "html-react-parser": "^3.0.4", "prop-types": "^15.8.1", diff --git a/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx b/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx index ff28cb83f8..f424b55148 100644 --- a/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx +++ b/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx @@ -12,9 +12,6 @@ import { import { convertAttributesToReactProps } from '../utils'; import { HiddenRendering, HIDDEN_RENDERING_NAME } from './HiddenRendering'; -/** [SXA] common marker by which we find container fo replacing **/ -const PREFIX_PLACEHOLDER = 'container-{*}'; - type ErrorComponentProps = { [prop: string]: unknown; }; @@ -121,10 +118,14 @@ export class PlaceholderCommon extends React.Compone ) { let result; /** [SXA] it needs for deleting dynamics placeholder when we set him number(props.name) of container. - from backend side we get common name of placeholder is called 'container-{*}' where '{*}' marker for replacing **/ - if (rendering && rendering.placeholders && rendering.placeholders[PREFIX_PLACEHOLDER]) { - rendering.placeholders[name] = rendering.placeholders[PREFIX_PLACEHOLDER]; - delete rendering.placeholders[PREFIX_PLACEHOLDER]; + from backend side we get common name of placeholder is called 'nameOfContainer-{*}' where '{*}' marker for replacing **/ + if (rendering?.placeholders) { + Object.keys(rendering.placeholders).forEach((placeholder) => { + if (placeholder.indexOf('{*}') !== -1) { + rendering.placeholders[name] = rendering.placeholders[placeholder]; + delete rendering.placeholders[placeholder]; + } + }); } if (rendering && rendering.placeholders && Object.keys(rendering.placeholders).length > 0) { diff --git a/packages/sitecore-jss-react/src/test-data/non-ee-data.ts b/packages/sitecore-jss-react/src/test-data/non-ee-data.ts index d8a402501c..a1327b3bb3 100644 --- a/packages/sitecore-jss-react/src/test-data/non-ee-data.ts +++ b/packages/sitecore-jss-react/src/test-data/non-ee-data.ts @@ -245,7 +245,7 @@ export const sxaRenderingVariantDataWithoutCommonContainerName = { }, }, placeholders: { - 'no-container-{*}': [ + 'no-container': [ { uid: 'c4d5d43b-5aa8-4e03-8f16-9428f3e02d5c', componentName: 'RichText', diff --git a/packages/sitecore-jss-rendering-host/package.json b/packages/sitecore-jss-rendering-host/package.json index fff63f52d1..2b0a0e5127 100644 --- a/packages/sitecore-jss-rendering-host/package.json +++ b/packages/sitecore-jss-rendering-host/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-rendering-host", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "sideEffects": false, diff --git a/packages/sitecore-jss-vue/jest.config.coverage.js b/packages/sitecore-jss-vue/jest.config.coverage.js index 70cda2cc02..066db4df38 100644 --- a/packages/sitecore-jss-vue/jest.config.coverage.js +++ b/packages/sitecore-jss-vue/jest.config.coverage.js @@ -5,6 +5,7 @@ const coverageConfig = { collectCoverageFrom: ['**/*.ts'], coverageDirectory: './coverage', coveragePathIgnorePatterns: [ + '/index.ts', '/node_modules/', '/dist/', '/test/', diff --git a/packages/sitecore-jss-vue/package.json b/packages/sitecore-jss-vue/package.json index f73e53d622..c238775bf5 100644 --- a/packages/sitecore-jss-vue/package.json +++ b/packages/sitecore-jss-vue/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss-vue", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "description": "A library for building Sitecore JSS apps using Vue.js", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -49,7 +49,7 @@ "vue": "^v3.1.0-beta.6" }, "dependencies": { - "@sitecore-jss/sitecore-jss": "^21.1.0-canary.57", + "@sitecore-jss/sitecore-jss": "^21.1.0-canary.64", "@vue/compiler-sfc": "^3.0.11" }, "types": "./types/index.d.ts", diff --git a/packages/sitecore-jss/package.json b/packages/sitecore-jss/package.json index 0a840f99bf..732e77d8e1 100644 --- a/packages/sitecore-jss/package.json +++ b/packages/sitecore-jss/package.json @@ -1,6 +1,6 @@ { "name": "@sitecore-jss/sitecore-jss", - "version": "21.1.0-canary.57", + "version": "21.1.0-canary.64", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "sideEffects": false, diff --git a/packages/sitecore-jss/src/personalize/graphql-personalize-service.test.ts b/packages/sitecore-jss/src/personalize/graphql-personalize-service.test.ts index 782b6bab05..a9820b2fc3 100644 --- a/packages/sitecore-jss/src/personalize/graphql-personalize-service.test.ts +++ b/packages/sitecore-jss/src/personalize/graphql-personalize-service.test.ts @@ -15,7 +15,6 @@ describe('GraphQLPersonalizeService', () => { const variantIds = ['variant-1', 'variant-2']; const config = { endpoint, - siteName, apiKey, }; const personalizeQueryResult = { @@ -64,7 +63,11 @@ describe('GraphQLPersonalizeService', () => { mockNonEmptyResponse(); const service = new GraphQLPersonalizeService(config); - const personalizeData = await service.getPersonalizeInfo('/sitecore/content/home', 'en'); + const personalizeData = await service.getPersonalizeInfo( + '/sitecore/content/home', + 'en', + siteName + ); expect(personalizeData).to.deep.equal({ contentId: `embedded_${id}_en`.toLowerCase(), @@ -76,7 +79,11 @@ describe('GraphQLPersonalizeService', () => { mockEmptyResponse(); const service = new GraphQLPersonalizeService(config); - const personalizeData = await service.getPersonalizeInfo('/sitecore/content/home', ''); + const personalizeData = await service.getPersonalizeInfo( + '/sitecore/content/home', + '', + siteName + ); expect(personalizeData).to.eql(undefined); }); @@ -91,7 +98,7 @@ describe('GraphQLPersonalizeService', () => { .replyWithError('error_test'); const service = new GraphQLPersonalizeService(config); - await service.getPersonalizeInfo('/sitecore/content/home', 'en').catch((error) => { + await service.getPersonalizeInfo('/sitecore/content/home', 'en', siteName).catch((error) => { expect(error.message).to.contain('error_test'); }); }); @@ -108,7 +115,7 @@ describe('GraphQLPersonalizeService', () => { const service = new GraphQLPersonalizeService(config); - const result = await service.getPersonalizeInfo('/sitecore/content/home', 'en'); + const result = await service.getPersonalizeInfo('/sitecore/content/home', 'en', siteName); expect(result).to.equal(undefined); }); it('should return fallback value when timeout is exceeded using provided timeout', async () => { @@ -123,7 +130,7 @@ describe('GraphQLPersonalizeService', () => { const service = new GraphQLPersonalizeService({ ...config, timeout: 50 }); - const result = await service.getPersonalizeInfo('/sitecore/content/home', 'en'); + const result = await service.getPersonalizeInfo('/sitecore/content/home', 'en', siteName); expect(result).to.equal(undefined); }); it('should return fallback value when api returns timeout error', async () => { @@ -137,7 +144,7 @@ describe('GraphQLPersonalizeService', () => { const service = new GraphQLPersonalizeService({ ...config, timeout: 50 }); - const result = await service.getPersonalizeInfo('/sitecore/content/home', 'en'); + const result = await service.getPersonalizeInfo('/sitecore/content/home', 'en', siteName); expect(result).to.equal(undefined); }); @@ -149,7 +156,7 @@ describe('GraphQLPersonalizeService', () => { const lang = 'en'; const service = new GraphQLPersonalizeService(config); - const firstResult = await service.getPersonalizeInfo(itemPath, lang); + const firstResult = await service.getPersonalizeInfo(itemPath, lang, siteName); expect(firstResult).to.deep.equal({ contentId: `embedded_${id}_en`.toLowerCase(), @@ -158,7 +165,7 @@ describe('GraphQLPersonalizeService', () => { mockEmptyResponse(); - const secondResult = await service.getPersonalizeInfo(itemPath, lang); + const secondResult = await service.getPersonalizeInfo(itemPath, lang, siteName); expect(secondResult).to.deep.equal(firstResult); }); @@ -173,7 +180,7 @@ describe('GraphQLPersonalizeService', () => { ...config, cacheEnabled: false, }); - const firstResult = await service.getPersonalizeInfo(itemPath, lang); + const firstResult = await service.getPersonalizeInfo(itemPath, lang, siteName); expect(firstResult).to.deep.equal({ contentId: `embedded_${id}_en`.toLowerCase(), @@ -182,7 +189,7 @@ describe('GraphQLPersonalizeService', () => { mockEmptyResponse(); - const secondResult = await service.getPersonalizeInfo(itemPath, lang); + const secondResult = await service.getPersonalizeInfo(itemPath, lang, siteName); expect(secondResult).to.not.deep.equal(firstResult); }); @@ -197,14 +204,14 @@ describe('GraphQLPersonalizeService', () => { ...config, cacheTimeout: 0.2, }); - const firstResult = await service.getPersonalizeInfo(itemPath, lang); + const firstResult = await service.getPersonalizeInfo(itemPath, lang, siteName); mockEmptyResponse(); const cacheNonUpdate = new Promise((resolve) => { setTimeout( () => - service.getPersonalizeInfo(itemPath, lang).then((newResult) => { + service.getPersonalizeInfo(itemPath, lang, siteName).then((newResult) => { expect(newResult).to.deep.equal(firstResult); resolve(undefined); }), @@ -215,7 +222,7 @@ describe('GraphQLPersonalizeService', () => { const cacheUpdate = new Promise((resolve) => { setTimeout( () => - service.getPersonalizeInfo(itemPath, lang).then((newResult) => { + service.getPersonalizeInfo(itemPath, lang, siteName).then((newResult) => { expect(newResult).to.deep.equal(undefined); resolve(undefined); }), diff --git a/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts b/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts index 810986c06c..89e5dc84ce 100644 --- a/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts +++ b/packages/sitecore-jss/src/personalize/graphql-personalize-service.ts @@ -9,10 +9,6 @@ export type GraphQLPersonalizeServiceConfig = CacheOptions & { * Your Graphql endpoint */ endpoint: string; - /** - * The JSS application name - */ - siteName: string; /** * The API key to use for authentication */ @@ -77,26 +73,23 @@ export class GraphQLPersonalizeService { * Get personalize information for a route * @param {string} itemPath page route * @param {string} language language + * @param {string} siteName site name * @returns {Promise} the personalize information or undefined (if itemPath / language not found) */ async getPersonalizeInfo( itemPath: string, - language: string + language: string, + siteName: string ): Promise { - debug.personalize( - 'fetching personalize info for %s %s %s', - this.config.siteName, - itemPath, - language - ); + debug.personalize('fetching personalize info for %s %s %s', siteName, itemPath, language); - const cacheKey = this.getCacheKey(itemPath, language); + const cacheKey = this.getCacheKey(itemPath, language, siteName); let data = this.cache.getCacheValue(cacheKey); if (!data) { try { data = await this.graphQLClient.request(this.query, { - siteName: this.config.siteName, + siteName, itemPath, language, }); @@ -130,8 +123,8 @@ export class GraphQLPersonalizeService { }); } - protected getCacheKey(itemPath: string, language: string) { - return `${this.config.siteName}-${itemPath}-${language}`; + protected getCacheKey(itemPath: string, language: string, siteName: string) { + return `${siteName}-${itemPath}-${language}`; } /** diff --git a/packages/sitecore-jss/src/site/graphql-redirects-service.test.ts b/packages/sitecore-jss/src/site/graphql-redirects-service.test.ts index a35df53979..2ced61e857 100644 --- a/packages/sitecore-jss/src/site/graphql-redirects-service.test.ts +++ b/packages/sitecore-jss/src/site/graphql-redirects-service.test.ts @@ -1,8 +1,12 @@ -import { expect } from 'chai'; +/* eslint-disable dot-notation */ +import { expect, use, spy } from 'chai'; +import spies from 'chai-spies'; import nock from 'nock'; import { GraphQLRedirectsService, RedirectsQueryResult } from './graphql-redirects-service'; import { siteNameError } from '../constants'; +use(spies); + const redirectsQueryResultNull = { site: { siteInfo: { @@ -34,7 +38,6 @@ describe('GraphQLRedirectsService', () => { const config = { endpoint, apiKey, - siteName, }; afterEach(() => { @@ -60,8 +63,8 @@ describe('GraphQLRedirectsService', () => { it('should get error if redirects has empty siteName', async () => { mockRedirectsRequest(); - const service = new GraphQLRedirectsService({ endpoint, apiKey, siteName: '' }); - await service.fetchRedirects().catch((error: Error) => { + const service = new GraphQLRedirectsService({ endpoint, apiKey }); + await service.fetchRedirects('').catch((error: Error) => { expect(error.message).to.equal(siteNameError); }); @@ -71,8 +74,8 @@ describe('GraphQLRedirectsService', () => { it('should get redirects', async () => { mockRedirectsRequest(siteName); - const service = new GraphQLRedirectsService({ endpoint, apiKey, siteName }); - const result = await service.fetchRedirects(); + const service = new GraphQLRedirectsService({ endpoint, apiKey }); + const result = await service.fetchRedirects(siteName); expect(result).to.deep.equal(redirectsQueryResult.site?.siteInfo?.redirects); @@ -82,8 +85,8 @@ describe('GraphQLRedirectsService', () => { it('should get no redirects', async () => { mockRedirectsRequest(); - const service = new GraphQLRedirectsService({ endpoint, apiKey, siteName }); - const result = await service.fetchRedirects(); + const service = new GraphQLRedirectsService({ endpoint, apiKey }); + const result = await service.fetchRedirects(siteName); expect(result).to.deep.equal(redirectsQueryResultNull.site?.siteInfo?.redirects); @@ -93,7 +96,7 @@ describe('GraphQLRedirectsService', () => { it('should cache fetch response', async () => { mockRedirectsRequest(siteName); const service = new GraphQLRedirectsService(config); - const redirectsResponse = await service.fetchRedirects(); + const redirectsResponse = await service.fetchRedirects(siteName); expect(redirectsResponse).to.deep.equal(redirectsQueryResult.site?.siteInfo?.redirects); @@ -107,7 +110,7 @@ describe('GraphQLRedirectsService', () => { }, }); - const cachedResponse = await service.fetchRedirects(); + const cachedResponse = await service.fetchRedirects(siteName); expect(cachedResponse).to.deep.equal(redirectsResponse); }); @@ -115,7 +118,7 @@ describe('GraphQLRedirectsService', () => { it('should be possible to disable cache', async () => { mockRedirectsRequest(siteName); const service = new GraphQLRedirectsService({ ...config, cacheEnabled: false }); - const redirectsResponse = await service.fetchRedirects(); + const redirectsResponse = await service.fetchRedirects(siteName); expect(redirectsResponse).to.deep.equal(redirectsQueryResult.site?.siteInfo?.redirects); @@ -129,9 +132,41 @@ describe('GraphQLRedirectsService', () => { }, }); - const cachedResponse = await service.fetchRedirects(); + const cachedResponse = await service.fetchRedirects(siteName); expect(cachedResponse).to.not.deep.equal(redirectsResponse); }); + + it('should use dynamic site name', async () => { + const dynamicSiteName = 'foo'; + mockRedirectsRequest(dynamicSiteName); + const service = new GraphQLRedirectsService(config); + + const getCacheValueSpy = spy.on(service['cache'], 'getCacheValue'); + const setCacheValueSpy = spy.on(service['cache'], 'setCacheValue'); + + const redirectsResponse = await service.fetchRedirects(dynamicSiteName); + + expect(getCacheValueSpy).to.have.been.called.with('redirects-foo'); + expect(setCacheValueSpy).to.have.been.called.with('redirects-foo', redirectsQueryResult); + + expect(redirectsResponse).to.deep.equal(redirectsQueryResult.site?.siteInfo?.redirects); + + nock.cleanAll(); + + nock(endpoint) + .post('/') + .reply(200, { + data: { + site: {}, + }, + }); + + const cachedResponse = await service.fetchRedirects(dynamicSiteName); + + expect(cachedResponse).to.deep.equal(redirectsResponse); + + spy.restore(service['cache']); + }); }); }); diff --git a/packages/sitecore-jss/src/site/graphql-redirects-service.ts b/packages/sitecore-jss/src/site/graphql-redirects-service.ts index dcf3575d4f..11adc17757 100644 --- a/packages/sitecore-jss/src/site/graphql-redirects-service.ts +++ b/packages/sitecore-jss/src/site/graphql-redirects-service.ts @@ -41,10 +41,6 @@ export type GraphQLRedirectsServiceConfig = CacheOptions & { * The API key to use for authentication */ apiKey: string; - /** - * The JSS application name - */ - siteName: string; /** * Override fetch method. Uses 'GraphQLRequestClient' default otherwise. */ @@ -80,12 +76,11 @@ export class GraphQLRedirectsService { /** * Fetch an array of redirects from API + * @param {string} siteName site name * @returns Promise * @throws {Error} if the siteName is empty. */ - async fetchRedirects(): Promise { - const siteName: string = this.options.siteName; - + async fetchRedirects(siteName: string): Promise { if (!siteName) { throw new Error(siteNameError); } diff --git a/yarn.lock b/yarn.lock index e0f14f6cde..093af00564 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4223,7 +4223,7 @@ __metadata: "@angular/platform-browser": ~11.2.6 "@angular/platform-browser-dynamic": ~11.2.6 "@angular/router": ~11.2.6 - "@sitecore-jss/sitecore-jss": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss": ^21.1.0-canary.64 "@types/jasmine": ^3.4.1 "@types/node": ^14.14.35 codelyzer: ^6.0.1 @@ -4254,7 +4254,7 @@ __metadata: version: 0.0.0-use.local resolution: "@sitecore-jss/sitecore-jss-cli@workspace:packages/sitecore-jss-cli" dependencies: - "@sitecore-jss/sitecore-jss-dev-tools": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss-dev-tools": ^21.1.0-canary.64 "@types/chai": ^4.2.3 "@types/cross-spawn": ^6.0.0 "@types/mocha": ^9.0.0 @@ -4286,11 +4286,11 @@ __metadata: languageName: unknown linkType: soft -"@sitecore-jss/sitecore-jss-dev-tools@^21.1.0-canary.57, @sitecore-jss/sitecore-jss-dev-tools@workspace:packages/sitecore-jss-dev-tools": +"@sitecore-jss/sitecore-jss-dev-tools@^21.1.0-canary.64, @sitecore-jss/sitecore-jss-dev-tools@workspace:packages/sitecore-jss-dev-tools": version: 0.0.0-use.local resolution: "@sitecore-jss/sitecore-jss-dev-tools@workspace:packages/sitecore-jss-dev-tools" dependencies: - "@sitecore-jss/sitecore-jss": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss": ^21.1.0-canary.64 "@types/chai": ^4.2.3 "@types/chokidar": ^2.1.3 "@types/del": ^4.0.0 @@ -4344,11 +4344,11 @@ __metadata: languageName: unknown linkType: soft -"@sitecore-jss/sitecore-jss-forms@^21.1.0-canary.57, @sitecore-jss/sitecore-jss-forms@workspace:packages/sitecore-jss-forms": +"@sitecore-jss/sitecore-jss-forms@^21.1.0-canary.64, @sitecore-jss/sitecore-jss-forms@workspace:packages/sitecore-jss-forms": version: 0.0.0-use.local resolution: "@sitecore-jss/sitecore-jss-forms@workspace:packages/sitecore-jss-forms" dependencies: - "@sitecore-jss/sitecore-jss": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss": ^21.1.0-canary.64 "@types/chai": ^4.1.6 "@types/chai-string": ^1.4.1 "@types/lodash.unescape": ^4.0.4 @@ -4371,9 +4371,9 @@ __metadata: version: 0.0.0-use.local resolution: "@sitecore-jss/sitecore-jss-nextjs@workspace:packages/sitecore-jss-nextjs" dependencies: - "@sitecore-jss/sitecore-jss": ^21.1.0-canary.57 - "@sitecore-jss/sitecore-jss-dev-tools": ^21.1.0-canary.57 - "@sitecore-jss/sitecore-jss-react": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss": ^21.1.0-canary.64 + "@sitecore-jss/sitecore-jss-dev-tools": ^21.1.0-canary.64 + "@sitecore-jss/sitecore-jss-react": ^21.1.0-canary.64 "@types/chai": ^4.2.2 "@types/chai-as-promised": ^7.1.3 "@types/chai-string": ^1.4.2 @@ -4444,7 +4444,7 @@ __metadata: version: 0.0.0-use.local resolution: "@sitecore-jss/sitecore-jss-react-forms@workspace:packages/sitecore-jss-react-forms" dependencies: - "@sitecore-jss/sitecore-jss-forms": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss-forms": ^21.1.0-canary.64 "@types/chai": ^4.3.4 "@types/enzyme": ^3.10.12 "@types/mocha": ^10.0.1 @@ -4484,7 +4484,7 @@ __metadata: "@babel/plugin-proposal-export-default-from": ^7.5.2 "@babel/preset-env": ^7.6.2 "@babel/preset-typescript": ^7.6.0 - "@sitecore-jss/sitecore-jss": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss": ^21.1.0-canary.64 "@types/jest": ^24.0.18 "@types/prop-types": ^15.7.3 "@types/react": ^16.9.5 @@ -4514,11 +4514,11 @@ __metadata: languageName: unknown linkType: soft -"@sitecore-jss/sitecore-jss-react@^21.1.0-canary.57, @sitecore-jss/sitecore-jss-react@workspace:packages/sitecore-jss-react": +"@sitecore-jss/sitecore-jss-react@^21.1.0-canary.64, @sitecore-jss/sitecore-jss-react@workspace:packages/sitecore-jss-react": version: 0.0.0-use.local resolution: "@sitecore-jss/sitecore-jss-react@workspace:packages/sitecore-jss-react" dependencies: - "@sitecore-jss/sitecore-jss": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss": ^21.1.0-canary.64 "@types/chai": ^4.3.4 "@types/chai-string": ^1.4.2 "@types/deep-equal": ^1.0.1 @@ -4592,7 +4592,7 @@ __metadata: resolution: "@sitecore-jss/sitecore-jss-vue@workspace:packages/sitecore-jss-vue" dependencies: "@babel/core": ^7.16.0 - "@sitecore-jss/sitecore-jss": ^21.1.0-canary.57 + "@sitecore-jss/sitecore-jss": ^21.1.0-canary.64 "@types/jest": ^26.0.23 "@vue/compiler-dom": ^3.2.21 "@vue/compiler-sfc": ^3.0.11 @@ -4614,7 +4614,7 @@ __metadata: languageName: unknown linkType: soft -"@sitecore-jss/sitecore-jss@^21.1.0-canary.57, @sitecore-jss/sitecore-jss@workspace:packages/sitecore-jss": +"@sitecore-jss/sitecore-jss@^21.1.0-canary.64, @sitecore-jss/sitecore-jss@workspace:packages/sitecore-jss": version: 0.0.0-use.local resolution: "@sitecore-jss/sitecore-jss@workspace:packages/sitecore-jss" dependencies: From aa2c943b7f2da9c49be3c0b3c903431a1434d329 Mon Sep 17 00:00:00 2001 From: Illia Kovalenko <23364749+illiakovalenko@users.noreply.github.com> Date: Tue, 10 Jan 2023 17:46:19 +0200 Subject: [PATCH 10/14] [Next.js][Multi-site] Support for "sc_site" query string parameter (#1283) * jss-cli unit test coverage: first batch * jss-cli unit test coverage: second batch * jss-cli unit tests: second batch * fix expected output for test not to fail across environments * lint * dev-tools unit test coverage first batch * dev-tools unit test coverage: second batch * resolve-scjssconfig test placeholder (for the future!) * exclude index file from jss-vue test coverage (#1266) * version v21.1.0-canary.58 [skip ci] * fix test for scjssconfig resolving * Update packages/sitecore-jss-dev-tools/src/manifest/manifest-manager.test.ts Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> * Update packages/sitecore-jss-dev-tools/src/manifest/manifest-manager.test.ts Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> * lint * adding test data for scjssconfig * some improvements to reject logic in resolve scjssconfig * lint numero 2 * version v21.1.0-canary.59 [skip ci] * #556667: fixed urls for sitemap * version v21.1.0-canary.60 [skip ci] * final batch * re-gen yarn.lock * yarn.lock re-update * version v21.1.0-canary.61 [skip ci] * #552985: fixed header styles * version v21.1.0-canary.62 [skip ci] * #546298: fixed style for showing hidden components * version v21.1.0-canary.63 [skip ci] * SiteResolver.resolve updates: removed 'language' from site resolution logic, return SiteInfo instead of site name * [Next.js][Multi-site] Multi-site middleware plugin * #559044: fixed rendering dynamic placeholder (#1278) * version v21.1.0-canary.64 [skip ci] * Adjust with latest site resolver changes * adjust * Add latest changes * Extra comment * extra fix * Extend unit tests * Revert cookie set change * Use response cookies instead of request * Add querystring support * Adjust changes according to review * Adjust changes * Adjust changes according to review * lint fix * Use `sc_site` request cookie when it's present Co-authored-by: Artem Alexeyenko Co-authored-by: Automated Build Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> Co-authored-by: Ruslan Matkovskyi Co-authored-by: Ruslan Matkovskyi <100142572+matkovskyi@users.noreply.github.com> --- .../middleware/multisite-middleware.test.ts | 88 ++++++++++++++++++- .../src/middleware/multisite-middleware.ts | 6 +- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.test.ts b/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.test.ts index 9ac9e41dc6..f727962c4e 100644 --- a/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.test.ts @@ -28,7 +28,13 @@ describe('MultisiteMiddleware', () => { clone() { return Object.assign({}, req.nextUrl); }, - ...props?.nextUrl, + searchParams: { + get(key) { + return req.nextUrl.searchParams[key]; + }, + ...props.searchParams, + }, + ...props.nextUrl, }, headers: { get(key: string) { @@ -308,6 +314,86 @@ describe('MultisiteMiddleware', () => { nextRewriteStub.restore(); }); + + it('sc_site querystring parameter is provided', async () => { + const req = createRequest({ searchParams: { sc_site: 'qsFoo' } }); + + const res = createResponse(); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getSite } = createMiddleware(); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('multisite middleware start: %o', { + pathname: '/styleguide', + hostname: 'foo.net', + }); + + validateDebugLog('multisite middleware end: %o', { + rewritePath: '/_site_qsFoo/styleguide', + siteName: 'qsFoo', + headers: { + 'x-sc-rewrite': '/_site_qsFoo/styleguide', + }, + cookies: { + ...res.cookies, + sc_site: 'qsFoo', + }, + }); + + expect(getSite.notCalled).equal(true); + + expect(finalRes).to.deep.equal(res); + + expect(nextRewriteStub).calledWith({ + ...req.nextUrl, + pathname: '/_site_qsFoo/styleguide', + }); + + nextRewriteStub.restore(); + }); + + it('sc_site cookie is provided', async () => { + const req = createRequest({ cookieValues: { sc_site: 'foobar' } }); + + const res = createResponse(); + + const nextRewriteStub = sinon.stub(nextjs.NextResponse, 'rewrite').returns(res); + + const { middleware, getSite } = createMiddleware(); + + const finalRes = await middleware.getHandler()(req, res); + + validateDebugLog('multisite middleware start: %o', { + pathname: '/styleguide', + hostname: 'foo.net', + }); + + validateDebugLog('multisite middleware end: %o', { + rewritePath: '/_site_foobar/styleguide', + siteName: 'foobar', + headers: { + 'x-sc-rewrite': '/_site_foobar/styleguide', + }, + cookies: { + ...res.cookies, + sc_site: 'foobar', + }, + }); + + expect(getSite.notCalled).equal(true); + + expect(finalRes).to.deep.equal(res); + + expect(nextRewriteStub).calledWith({ + ...req.nextUrl, + pathname: '/_site_foobar/styleguide', + }); + + nextRewriteStub.restore(); + }); }); describe('error handling', () => { diff --git a/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.ts b/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.ts index 60d80ea301..08ee7938bd 100644 --- a/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/middleware/multisite-middleware.ts @@ -94,7 +94,11 @@ export class MultisiteMiddleware { return response; } - const { name: siteName } = this.config.getSite(hostname); + // Site name can be forced by query string parameter or cookie + const siteName = + req.nextUrl.searchParams.get('sc_site') || + req.cookies.get('sc_site') || + this.config.getSite(hostname).name; // Rewrite to site specific path const rewritePath = getSiteRewrite(pathname, { From 2a6e7bf3e7f174d07a0d378b3e3284355a44a289 Mon Sep 17 00:00:00 2001 From: illiakovalenko Date: Wed, 11 Jan 2023 12:59:41 +0200 Subject: [PATCH 11/14] Import SiteResolver from `middleware` submodule --- .../src/templates/nextjs/src/lib/site-resolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/site-resolver.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/site-resolver.ts index 52df6f09c3..00f8d973cd 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/site-resolver.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/site-resolver.ts @@ -1,4 +1,4 @@ -import { SiteResolver } from '@sitecore-jss/sitecore-jss-nextjs'; +import { SiteResolver } from '@sitecore-jss/sitecore-jss-nextjs/middleware'; import config from 'temp/config'; /* From fd498aebe54c4cce1b19f88e7d97336e4f37fd37 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Thu, 12 Jan 2023 08:40:06 -0500 Subject: [PATCH 12/14] Ensure site and variant prefixes are position agnostic (#1286) * personalize rewrite unit tests (cherry picked from commit 3c412ed6855d60452e1e29d21a92d1901312d3d4) * site path utils unit tests, tweak to personalize path unit test --- .../sitecore-jss/src/personalize/utils.test.ts | 14 ++++++++++++++ packages/sitecore-jss/src/site/utils.test.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/sitecore-jss/src/personalize/utils.test.ts b/packages/sitecore-jss/src/personalize/utils.test.ts index 4473731487..e59596d559 100644 --- a/packages/sitecore-jss/src/personalize/utils.test.ts +++ b/packages/sitecore-jss/src/personalize/utils.test.ts @@ -52,6 +52,13 @@ describe('utils', () => { const result = getPersonalizedRewriteData(pathname); expect(result.variantId).to.equal(''); }); + it('should return varinat id from any position in pathname', () => { + const testId = '0451'; + const path1 = `/${VARIANT_PREFIX}${testId}/some/path/`; + const path2 = `/_site_mysite/${VARIANT_PREFIX}${testId}/some/path/`; + + expect(getPersonalizedRewriteData(path1)).to.deep.equal(getPersonalizedRewriteData(path2)); + }); }); describe('normalizePersonalizedRewrite', () => { @@ -78,6 +85,13 @@ describe('utils', () => { const result = normalizePersonalizedRewrite(pathname); expect(result).to.equal('/'); }); + it('should normalize path with other prefixes present', () => { + const pathname = `/_site_mysite/${VARIANT_PREFIX}foo`; + const pathNameInversed = `/${VARIANT_PREFIX}foo/_site_mysite/`; + const result = normalizePersonalizedRewrite(pathname); + expect(result).to.equal('/_site_mysite/'); + expect(normalizePersonalizedRewrite(pathNameInversed)).to.equal(result); + }); }); describe('CdpHelper', () => { diff --git a/packages/sitecore-jss/src/site/utils.test.ts b/packages/sitecore-jss/src/site/utils.test.ts index 83e09d891f..e3744787b4 100644 --- a/packages/sitecore-jss/src/site/utils.test.ts +++ b/packages/sitecore-jss/src/site/utils.test.ts @@ -54,6 +54,14 @@ describe('utils', () => { const result = getSiteRewriteData(pathname, defaultSiteName); expect(result.siteName).to.equal(defaultSiteName); }); + + it('should return site name from anywhere in the path', () => { + const siteName = 'fiftyone'; + const path1 = `/${SITE_PREFIX}${siteName}/some/path/`; + const path2 = `/_variantId_0451/${SITE_PREFIX}${siteName}/some/path/`; + + expect(getSiteRewriteData(path1, defaultSiteName)).to.deep.equal(getSiteRewriteData(path2, defaultSiteName)); + }); }); describe('normalizeSiteRewrite', () => { @@ -84,5 +92,14 @@ describe('utils', () => { const result = normalizeSiteRewrite(pathname); expect(result).to.equal('/'); }); + + it('should normalize path with other prefixes present', () => { + const pathnameWithPrefix = `/_variantId_0451/${SITE_PREFIX}foo`; + const pathnameWithPostfix = `/${SITE_PREFIX}foo/_variantId_0451/`; + const resultPrefix = normalizeSiteRewrite(pathnameWithPrefix); + const resultPostfix = normalizeSiteRewrite(pathnameWithPostfix); + expect(resultPrefix).to.equal('/_variantId_0451/'); + expect(resultPrefix).to.equal(resultPostfix); + }); }); }); From 835134acde6c328bce32e25b2cbe80dc812a715e Mon Sep 17 00:00:00 2001 From: Addy Pathania <89087450+sc-addypathania@users.noreply.github.com> Date: Thu, 12 Jan 2023 10:51:07 -0500 Subject: [PATCH 13/14] [MultiSite] SSG support (#1284) * added ssg support for multisite * added changes from review comments * added sitename for debug log * refactored sitemap methods * fixed debug sitename * add error when no sites * removed commented code * refactored transformLanguageSitePaths * Update packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts Co-authored-by: Adam Brauer <400763+ambrauer@users.noreply.github.com> --- .../plugins/graphql-sitemap-service.ts | 11 +- .../services/graphql-sitemap-service.test.ts | 291 +++++++++++++++--- .../src/services/graphql-sitemap-service.ts | 100 ++++-- 3 files changed, 335 insertions(+), 67 deletions(-) diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts index 25c3f0cf7f..2e6426ba81 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts @@ -1,8 +1,13 @@ -import { GraphQLSitemapService } from '@sitecore-jss/sitecore-jss-nextjs'; +import { + GraphQLSitemapService, + StaticPath, + constants, + siteInfo, +} from '@sitecore-jss/sitecore-jss-nextjs'; import config from 'temp/config'; import { SitemapFetcherPlugin } from '..'; import { GetStaticPathsContext } from 'next'; -import { StaticPath, constants } from '@sitecore-jss/sitecore-jss-nextjs'; +import siteResolver from 'lib/site-resolver'; class GraphqlSitemapServicePlugin implements SitemapFetcherPlugin { _graphqlSitemapService: GraphQLSitemapService; @@ -11,7 +16,7 @@ class GraphqlSitemapServicePlugin implements SitemapFetcherPlugin { this._graphqlSitemapService = new GraphQLSitemapService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - siteName: config.jssAppName, + sites: siteResolver.sites.map((site: siteInfo) => site.name), }); } diff --git a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.test.ts b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.test.ts index 8d26e06aec..f517e37496 100644 --- a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.test.ts +++ b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.test.ts @@ -1,4 +1,4 @@ -import { expect } from 'chai'; +import { expect } from 'chai'; import nock from 'nock'; import { getSiteEmptyError, @@ -51,7 +51,7 @@ it('should return null if no app root found', async () => { describe('GraphQLSitemapService', () => { const endpoint = 'http://site'; const apiKey = 'some-api-key'; - const siteName = 'site-name'; + const sites = ['site-name']; afterEach(() => { nock.cleanAll(); @@ -86,7 +86,7 @@ describe('GraphQLSitemapService', () => { it('should work when 1 language is requested', async () => { mockPathsRequest(); - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); const sitemap = await service.fetchSSGSitemap(['ua']); expect(sitemap).to.deep.equal(sitemapServiceResult); @@ -155,7 +155,7 @@ describe('GraphQLSitemapService', () => { const service = new GraphQLSitemapService({ endpoint, apiKey, - siteName, + sites, includedPaths, excludedPaths, }); @@ -181,7 +181,7 @@ describe('GraphQLSitemapService', () => { const service = new GraphQLSitemapService({ endpoint, apiKey, - siteName, + sites, includePersonalizedRoutes: true, }); const sitemap = await service.fetchSSGSitemap([lang]); @@ -227,6 +227,236 @@ describe('GraphQLSitemapService', () => { return expect(nock.isDone()).to.be.true; }); + it('should return aggregated paths for multiple sites when no personalized site', async () => { + const multipleSites = ['site1', 'site2']; + const lang = 'ua'; + + nock(endpoint) + .persist() + .post('/', (body) => { + return body.variables.siteName === multipleSites[0]; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + { + path: '/y1/y2/y3/y4', + }, + { + path: '/y1/y2', + }, + ], + }, + }, + }, + }, + }); + + nock(endpoint) + .persist() + .post('/', (body) => { + return body.variables.siteName === multipleSites[1]; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 2, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/y1', + }, + { + path: '/x1/x2', + }, + ], + }, + }, + }, + }, + }); + + const service = new GraphQLSitemapService({ + endpoint, + apiKey, + sites: multipleSites, + }); + const sitemap = await service.fetchSSGSitemap([lang]); + + expect(sitemap).to.deep.equal([ + { + params: { + path: ['_site_site1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site1', 'x1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site1', 'y1', 'y2'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'y1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'x1', 'x2'], + }, + locale: lang, + }, + ]); + return expect(nock.isDone()).to.be.true; + }); + + it('should return aggregated paths for multiple sites and personalized sites', async () => { + const multipleSites = ['site1', 'site2']; + const lang = 'ua'; + + nock(endpoint) + .post('/', /PersonalizeSitemapQuery/gi) + .reply(200, sitemapPersonalizeQueryResult); + + nock(endpoint) + .persist() + .post('/', (body) => { + return body.variables.siteName === multipleSites[1]; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + { + path: '/y1/y2/y3/y4', + }, + { + path: '/y1/y2', + }, + ], + }, + }, + }, + }, + }); + + const service = new GraphQLSitemapService({ + endpoint, + apiKey, + sites: multipleSites, + includePersonalizedRoutes: true, + }); + const sitemap = await service.fetchSSGSitemap([lang]); + console.log(sitemap.map((item) => item.params.path)); + + expect(sitemap).to.deep.equal([ + { + params: { + path: ['_site_site1'], + }, + locale: lang, + }, + { + params: { + path: ['_variantId_green', '_site_site1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_variantId_green', '_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_variantId_red', '_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_variantId_purple', '_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'x1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'y1', 'y2'], + }, + locale: lang, + }, + ]); + return expect(nock.isDone()).to.be.true; + }); + it('should work when multiple languages are requested', async () => { const lang1 = 'ua'; const lang2 = 'da-DK'; @@ -297,7 +527,7 @@ describe('GraphQLSitemapService', () => { }, }); - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); const sitemap = await service.fetchSSGSitemap([lang1, lang2]); expect(sitemap).to.deep.equal([ @@ -386,7 +616,7 @@ describe('GraphQLSitemapService', () => { }, }); - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); const sitemap = await service.fetchSSGSitemap([lang]); expect(sitemap).to.deep.equal([ @@ -408,17 +638,17 @@ describe('GraphQLSitemapService', () => { }); it('should throw error if valid language is not provided', async () => { - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); await service.fetchSSGSitemap([]).catch((error: RangeError) => { expect(error.message).to.equal(languageError); }); }); it('should throw error if query returns nothing for a provided site name', async () => { - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); nock(endpoint) .post('/', (body) => { - return body.variables.siteName === siteName; + return body.variables.siteName === sites[0]; }) .reply(200, { data: { @@ -428,14 +658,14 @@ describe('GraphQLSitemapService', () => { }, }); await service.fetchSSGSitemap(['en']).catch((error: RangeError) => { - expect(error.message).to.equal(getSiteEmptyError(siteName)); + expect(error.message).to.equal(getSiteEmptyError(sites[0])); }); }); it('should throw error if empty language is provided', async () => { mockPathsRequest(); - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); await service.fetchExportSitemap('').catch((error: RangeError) => { expect(error.message).to.equal('The language must be a non-empty string'); }); @@ -453,7 +683,7 @@ describe('GraphQLSitemapService', () => { const service = new GraphQLSitemapService({ endpoint, apiKey, - siteName, + sites, pageSize: customPageSize, }); const sitemap = await service.fetchSSGSitemap(['ua']); @@ -474,7 +704,7 @@ describe('GraphQLSitemapService', () => { const service = new GraphQLSitemapService({ endpoint, apiKey, - siteName, + sites, pageSize: undefined, }); const sitemap = await service.fetchSSGSitemap(['ua']); @@ -486,7 +716,7 @@ describe('GraphQLSitemapService', () => { it('should work if sitemap has 0 pages', async () => { mockPathsRequest([]); - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); const sitemap = await service.fetchSSGSitemap(['ua']); expect(sitemap).to.deep.equal([]); return expect(nock.isDone()).to.be.true; @@ -497,7 +727,7 @@ describe('GraphQLSitemapService', () => { .post('/', /DefaultSitemapQuery/gi) .reply(500, 'Error 😥'); - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); await service.fetchSSGSitemap(['ua']).catch((error: RangeError) => { expect(error.message).to.contain('SitemapQuery'); expect(error.message).to.contain('Error 😥'); @@ -532,54 +762,43 @@ describe('GraphQLSitemapService', () => { describe('Fetch sitemap in export mode', () => { it('should fetch sitemap', async () => { mockPathsRequest(); - - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); const sitemap = await service.fetchExportSitemap('ua'); - expect(sitemap).to.deep.equal(expectedExportSitemap); return expect(nock.isDone()).to.be.true; }); - it('should work if endpoint returns 0 pages', async () => { mockPathsRequest([]); - - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); const sitemap = await service.fetchExportSitemap('ua'); - expect(sitemap).to.deep.equal([]); return expect(nock.isDone()).to.be.true; }); - it('should throw error if SitemapQuery fails', async () => { nock(endpoint) .post('/', /DefaultSitemapQuery/gi) .reply(500, 'Error 😥'); - - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); await service.fetchExportSitemap('ua').catch((error: RangeError) => { expect(error.message).to.contain('SitemapQuery'); expect(error.message).to.contain('Error 😥'); }); return expect(nock.isDone()).to.be.true; }); - it('should throw error if language is not provided', async () => { mockPathsRequest(); - - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); await service.fetchExportSitemap('').catch((error: RangeError) => { expect(error.message).to.equal('The language must be a non-empty string'); }); - return expect(nock.isDone()).to.be.false; }); }); - it('should throw error if query returns nothing for a provided site name', async () => { - const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); nock(endpoint) .post('/', (body) => { - return body.variables.siteName === siteName; + return body.variables.siteName === sites[0]; }) .reply(200, { data: { @@ -589,17 +808,15 @@ describe('GraphQLSitemapService', () => { }, }); await service.fetchExportSitemap('en').catch((error: RangeError) => { - expect(error.message).to.equal(getSiteEmptyError(siteName)); + expect(error.message).to.equal(getSiteEmptyError(sites[0])); }); }); - it('should provide a default GraphQL client', () => { const service = new TestService({ endpoint, apiKey, - siteName, + sites, }); - const graphQLClient = service.client as GraphQLClient; const graphQLRequestClient = service.client as GraphQLRequestClient; // eslint-disable-next-line no-unused-expressions diff --git a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts index 4ec7453d0f..dba978ea96 100644 --- a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts +++ b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts @@ -1,9 +1,11 @@ import { GraphQLClient, GraphQLRequestClient, PageInfo } from '@sitecore-jss/sitecore-jss/graphql'; import { debug } from '@sitecore-jss/sitecore-jss'; import { getPersonalizedRewrite } from '@sitecore-jss/sitecore-jss/personalize'; +import { getSiteRewrite } from '@sitecore-jss/sitecore-jss/site'; /** @private */ export const languageError = 'The list of languages cannot be empty'; +export const sitesError = 'The list of sites cannot be empty'; /** * @param {string} siteName to inject into error text @@ -44,7 +46,7 @@ query ${usesPersonalize ? 'PersonalizeSitemapQuery' : 'DefaultSitemapQuery'}( hasNext } results { - path: routePath + path: routePath ${ usesPersonalize ? ` @@ -59,7 +61,7 @@ query ${usesPersonalize ? 'PersonalizeSitemapQuery' : 'DefaultSitemapQuery'}( } } } -} +} `; /** * type for input variables for the site routes query @@ -123,7 +125,8 @@ export type RouteListQueryResult = { /** * Configuration options for @see GraphQLSitemapService instances */ -export interface GraphQLSitemapServiceConfig extends Omit { +export interface GraphQLSitemapServiceConfig + extends Omit { /** * Your Graphql endpoint */ @@ -134,6 +137,11 @@ export interface GraphQLSitemapServiceConfig extends Omit StaticPath ): Promise { + const paths = new Array(); if (!languages.length) { throw new RangeError(languageError); } - // Fetch paths using all locales - const paths = await Promise.all( - languages.map((language) => { - if (language === '') { - throw new RangeError(languageEmptyError); - } - debug.sitemap('fetching sitemap data for %s', language); - return this.fetchLanguageSitePaths(language).then((results) => - this.transformLanguageSitePaths(results, formatStaticPath, language) - ); - }) - ); - - // merge promises results into single result + + // Get all sites + const sites = this.options.sites; + if (!sites || !sites.length) { + throw new RangeError(sitesError); + } + + // Fetch paths for each site + for (let i = 0; i < sites.length; i++) { + const siteName = sites[i]; + const multiSiteName = sites.length > 1 ? siteName : undefined; + + // Fetch paths using all locales + await Promise.all( + languages.map(async (language) => { + if (language === '') { + throw new RangeError(languageEmptyError); + } + debug.sitemap('fetching sitemap data for %s %s', language, siteName); + const results = await this.fetchLanguageSitePaths(language, siteName); + const transformedPaths = await this.transformLanguageSitePaths( + results, + formatStaticPath, + language, + multiSiteName + ); + paths.push(...transformedPaths); + }) + ); + } + return ([] as StaticPath[]).concat(...paths); } protected async transformLanguageSitePaths( sitePaths: RouteListQueryResult[], formatStaticPath: (path: string[], language: string) => StaticPath, - language: string + language: string, + multiSiteName?: string ): Promise { const formatPath = (path: string) => formatStaticPath(path.replace(/^\/|\/$/g, '').split('/'), language); + const aggregatedPaths: StaticPath[] = []; sitePaths.forEach((item) => { if (!item) return; - aggregatedPaths.push(formatPath(item.path)); + if (!multiSiteName) { + aggregatedPaths.push(formatPath(item.path)); + } else { + aggregatedPaths.push(formatPath(getSiteRewrite(item.path, { siteName: multiSiteName }))); + } + // check for type safety's sake - personalize may be empty depending on query type if (item.route?.personalization?.variantIds.length) { - aggregatedPaths.push( - ...item.route?.personalization?.variantIds.map((varId) => - formatPath(getPersonalizedRewrite(item.path, { variantId: varId })) - ) - ); + multiSiteName + ? aggregatedPaths.push( + ...item.route?.personalization?.variantIds.map((varId) => + formatPath( + getPersonalizedRewrite(getSiteRewrite(item.path, { siteName: multiSiteName }), { + variantId: varId, + }) + ) + ) + ) + : aggregatedPaths.push( + ...item.route?.personalization?.variantIds.map((varId) => + formatPath(getPersonalizedRewrite(item.path, { variantId: varId })) + ) + ); } }); return aggregatedPaths; } - protected async fetchLanguageSitePaths(language: string): Promise { + protected async fetchLanguageSitePaths( + language: string, + siteName: string + ): Promise { const args: SiteRouteQueryVariables = { - siteName: this.options.siteName, + siteName: siteName, language: language, pageSize: this.options.pageSize, includedPaths: this.options.includedPaths, @@ -287,7 +333,7 @@ export class GraphQLSitemapService { }); if (!fetchResponse?.site?.siteInfo) { - throw new RangeError(getSiteEmptyError(this.options.siteName)); + throw new RangeError(getSiteEmptyError(siteName)); } else { results = results.concat(fetchResponse.site.siteInfo.routes?.results); hasNext = fetchResponse.site.siteInfo.routes?.pageInfo.hasNext; From 681732c7b14430ad4c4de9823813819964968a9d Mon Sep 17 00:00:00 2001 From: Adam Brauer <400763+ambrauer@users.noreply.github.com> Date: Thu, 12 Jan 2023 13:56:11 -0600 Subject: [PATCH 14/14] lint fix --- packages/sitecore-jss/src/site/utils.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sitecore-jss/src/site/utils.test.ts b/packages/sitecore-jss/src/site/utils.test.ts index e3744787b4..0a71062fd3 100644 --- a/packages/sitecore-jss/src/site/utils.test.ts +++ b/packages/sitecore-jss/src/site/utils.test.ts @@ -60,7 +60,9 @@ describe('utils', () => { const path1 = `/${SITE_PREFIX}${siteName}/some/path/`; const path2 = `/_variantId_0451/${SITE_PREFIX}${siteName}/some/path/`; - expect(getSiteRewriteData(path1, defaultSiteName)).to.deep.equal(getSiteRewriteData(path2, defaultSiteName)); + expect(getSiteRewriteData(path1, defaultSiteName)).to.deep.equal( + getSiteRewriteData(path2, defaultSiteName) + ); }); });