diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index 45510cd12d..634afad24b 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -6,6 +6,8 @@ export { AxiosDataFetcher, AxiosDataFetcherConfig, LayoutService, + GraphQLLayoutService, + GraphQLLayoutServiceConfig, RestLayoutService, RestLayoutServiceConfig, DictionaryPhrases, diff --git a/packages/sitecore-jss/src/index.ts b/packages/sitecore-jss/src/index.ts index e4c06a5a14..5176131342 100644 --- a/packages/sitecore-jss/src/index.ts +++ b/packages/sitecore-jss/src/index.ts @@ -11,6 +11,8 @@ export { AxiosDataFetcher, AxiosDataFetcherConfig } from './data-fetcher'; export { LayoutService, + GraphQLLayoutService, + GraphQLLayoutServiceConfig, RestLayoutService, RestLayoutServiceConfig, DataFetcherResolver, diff --git a/packages/sitecore-jss/src/layout-service.test.ts b/packages/sitecore-jss/src/layout-service.test.ts index ae223dd5bc..0cf54a5c50 100644 --- a/packages/sitecore-jss/src/layout-service.test.ts +++ b/packages/sitecore-jss/src/layout-service.test.ts @@ -3,7 +3,8 @@ import { expect, spy, use } from 'chai'; import spies from 'chai-spies'; -import { RestLayoutService } from './layout-service'; +import nock from 'nock'; +import { GraphQLLayoutService, RestLayoutService } from './layout-service'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { IncomingMessage, ServerResponse } from 'http'; @@ -12,279 +13,457 @@ import { LayoutServiceData, PlaceholderData } from './dataModels'; use(spies); -describe('RestLayoutService', () => { - let mock: MockAdapter; +describe('LayoutService', () => { + describe('RestLayoutService', () => { + let mock: MockAdapter; - before(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.reset(); - }); - - after(() => { - mock.restore(); - }); + before(() => { + mock = new MockAdapter(axios); + }); - it('should fetch route data', () => { - mock.onGet().reply((config) => { - return [200, { ...config, data: { sitecore: { context: {}, route: { name: 'xxx' } } } }]; + afterEach(() => { + mock.reset(); }); - const service = new RestLayoutService({ - apiHost: 'http://sctest', - apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', - siteName: 'supersite', + after(() => { + mock.restore(); }); - return service.fetchLayoutData('/styleguide', 'en').then((layoutServiceData: any) => { - expect(layoutServiceData.url).to.equal( - 'http://sctest/sitecore/api/layout/render/jss?item=%2Fstyleguide&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=en&tracking=true' - ); - expect(layoutServiceData.data).to.deep.equal({ - sitecore: { - context: {}, - route: { name: 'xxx' }, - }, + it('should fetch route data', () => { + mock.onGet().reply((config) => { + return [200, { ...config, data: { sitecore: { context: {}, route: { name: 'xxx' } } } }]; + }); + + const service = new RestLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + + return service.fetchLayoutData('/styleguide', 'en').then((layoutServiceData: any) => { + expect(layoutServiceData.url).to.equal( + 'http://sctest/sitecore/api/layout/render/jss?item=%2Fstyleguide&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=en&tracking=true' + ); + expect(layoutServiceData.data).to.deep.equal({ + sitecore: { + context: {}, + route: { name: 'xxx' }, + }, + }); }); }); - }); - it('should fetch layout data and invoke callbacks', () => { - mock.onGet().reply((config) => { - return [ - 200, - { - ...config, - data: { sitecore: { context: {}, route: { name: 'xxx' } } }, + it('should fetch layout data and invoke callbacks', () => { + mock.onGet().reply((config) => { + return [ + 200, + { + ...config, + data: { sitecore: { context: {}, route: { name: 'xxx' } } }, + }, + { + 'set-cookie': 'test-set-cookie-value', + }, + ]; + }); + + const req = { + connection: { + remoteAddress: '192.168.1.10', }, - { - 'set-cookie': 'test-set-cookie-value', + headers: { + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', }, - ]; - }); + } as IncomingMessage; - const req = { - connection: { - remoteAddress: '192.168.1.10', - }, - headers: { - cookie: 'test-cookie-value', - referer: 'http://sctest', - 'user-agent': 'test-user-agent-value', - }, - } as IncomingMessage; - - const setHeaderSpy: any = spy(); - - const res = { - setHeader: setHeaderSpy, - } as ServerResponse; - - const service = new RestLayoutService({ - apiHost: 'http://sctest', - apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', - siteName: 'supersite', - tracking: false, - }); + const setHeaderSpy: any = spy(); - return service.fetchLayoutData('/home', 'da-DK', req, res).then((layoutServiceData: any) => { - expect(layoutServiceData.headers.cookie).to.equal('test-cookie-value'); - expect(layoutServiceData.headers.referer).to.equal('http://sctest'); - expect(layoutServiceData.headers['user-agent']).to.equal('test-user-agent-value'); - expect(layoutServiceData.headers['X-Forwarded-For']).to.equal('192.168.1.10'); + const res = { + setHeader: setHeaderSpy, + } as ServerResponse; - expect(layoutServiceData.url).to.equal( - 'http://sctest/sitecore/api/layout/render/jss?item=%2Fhome&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=da-DK&tracking=false' - ); - expect(layoutServiceData.data).to.deep.equal({ - sitecore: { - context: {}, - route: { name: 'xxx' }, - }, + const service = new RestLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + tracking: false, + }); + + return service.fetchLayoutData('/home', 'da-DK', req, res).then((layoutServiceData: any) => { + expect(layoutServiceData.headers.cookie).to.equal('test-cookie-value'); + expect(layoutServiceData.headers.referer).to.equal('http://sctest'); + expect(layoutServiceData.headers['user-agent']).to.equal('test-user-agent-value'); + expect(layoutServiceData.headers['X-Forwarded-For']).to.equal('192.168.1.10'); + + expect(layoutServiceData.url).to.equal( + 'http://sctest/sitecore/api/layout/render/jss?item=%2Fhome&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=da-DK&tracking=false' + ); + expect(layoutServiceData.data).to.deep.equal({ + sitecore: { + context: {}, + route: { name: 'xxx' }, + }, + }); + expect(setHeaderSpy).to.be.called.with('set-cookie', 'test-set-cookie-value'); }); - expect(setHeaderSpy).to.be.called.with('set-cookie', 'test-set-cookie-value'); }); - }); - it('should fetch layout data', () => { - mock.onGet().reply((config) => { - return [ - 200, - { - ...config, - data: { sitecore: { context: {}, route: { name: 'xxx' } } }, + it('should fetch layout data', () => { + mock.onGet().reply((config) => { + return [ + 200, + { + ...config, + data: { sitecore: { context: {}, route: { name: 'xxx' } } }, + }, + { + 'set-cookie': 'test-set-cookie-value', + }, + ]; + }); + + const req = { + connection: { + remoteAddress: '192.168.1.10', }, - { - 'set-cookie': 'test-set-cookie-value', + headers: { + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', }, - ]; + } as IncomingMessage; + + const setHeaderSpy: any = spy(); + + const res = { + setHeader: setHeaderSpy, + } as ServerResponse; + + const service = new RestLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + tracking: false, + }); + + return service.fetchLayoutData('/home', 'da-DK', req, res).then((layoutServiceData: any) => { + expect(layoutServiceData.headers.cookie).to.equal('test-cookie-value'); + expect(layoutServiceData.headers.referer).to.equal('http://sctest'); + expect(layoutServiceData.headers['user-agent']).to.equal('test-user-agent-value'); + expect(layoutServiceData.headers['X-Forwarded-For']).to.equal('192.168.1.10'); + + expect(layoutServiceData.url).to.equal( + 'http://sctest/sitecore/api/layout/render/jss?item=%2Fhome&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=da-DK&tracking=false' + ); + expect(layoutServiceData.data).to.deep.equal({ + sitecore: { + context: {}, + route: { name: 'xxx' }, + }, + }); + expect(setHeaderSpy).to.be.called.with('set-cookie', 'test-set-cookie-value'); + }); }); - const req = { - connection: { - remoteAddress: '192.168.1.10', - }, - headers: { - cookie: 'test-cookie-value', - referer: 'http://sctest', - 'user-agent': 'test-user-agent-value', - }, - } as IncomingMessage; - - const setHeaderSpy: any = spy(); - - const res = { - setHeader: setHeaderSpy, - } as ServerResponse; - - const service = new RestLayoutService({ - apiHost: 'http://sctest', - apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', - siteName: 'supersite', - tracking: false, + it('should fetch layout data using custom fetcher resolver', () => { + const fetcherSpy = spy((url: string) => { + return new AxiosDataFetcher().fetch(url); + }); + + mock.onGet().reply(() => { + return [200, { sitecore: { context: {}, route: { name: 'xxx' } } }]; + }); + + const service = new RestLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + dataFetcherResolver: () => fetcherSpy, + }); + + return service + .fetchLayoutData('/home', 'da-DK') + .then((layoutServiceData: LayoutServiceData) => { + expect(layoutServiceData).to.deep.equal({ + sitecore: { + context: {}, + route: { name: 'xxx' }, + }, + }); + + expect(fetcherSpy).to.be.called.once; + expect(fetcherSpy).to.be.called.with( + 'http://sctest/sitecore/api/layout/render/jss?item=%2Fhome&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=da-DK&tracking=true' + ); + }); }); - return service.fetchLayoutData('/home', 'da-DK', req, res).then((layoutServiceData: any) => { - expect(layoutServiceData.headers.cookie).to.equal('test-cookie-value'); - expect(layoutServiceData.headers.referer).to.equal('http://sctest'); - expect(layoutServiceData.headers['user-agent']).to.equal('test-user-agent-value'); - expect(layoutServiceData.headers['X-Forwarded-For']).to.equal('192.168.1.10'); + it('should fetch placeholder data', () => { + mock.onGet().reply(() => { + return [ + 200, + { + name: 'x1', + path: 'x1/x2', + elements: [], + }, + { + 'set-cookie': 'test-set-cookie-value', + }, + ]; + }); - expect(layoutServiceData.url).to.equal( - 'http://sctest/sitecore/api/layout/render/jss?item=%2Fhome&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=da-DK&tracking=false' - ); - expect(layoutServiceData.data).to.deep.equal({ - sitecore: { - context: {}, - route: { name: 'xxx' }, + const req = { + connection: { + remoteAddress: '192.168.1.10', + }, + headers: { + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', }, + } as IncomingMessage; + + const setHeaderSpy: any = spy(); + + const res = { + setHeader: setHeaderSpy, + } as ServerResponse; + + const service = new RestLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + tracking: false, }); - expect(setHeaderSpy).to.be.called.with('set-cookie', 'test-set-cookie-value'); - }); - }); - it('should fetch layout data using custom fetcher resolver', () => { - const fetcherSpy = spy((url: string) => { - return new AxiosDataFetcher().fetch(url); + return service + .fetchPlaceholderData('superPh', '/xxx', 'da-DK', req, res) + .then((placeholderData: PlaceholderData) => { + expect(placeholderData).to.deep.equal({ + name: 'x1', + path: 'x1/x2', + elements: [], + }); + expect(setHeaderSpy).to.be.called.with('set-cookie', 'test-set-cookie-value'); + }); }); - mock.onGet().reply(() => { - return [200, { sitecore: { context: {}, route: { name: 'xxx' } } }]; + it('should fetch placeholder data using custom fetcher resolver', () => { + const fetcherSpy = spy((url: string) => { + return new AxiosDataFetcher().fetch(url); + }); + + mock.onGet().reply(() => { + return [ + 200, + { + name: 'x1', + path: 'x1/x2', + elements: [], + }, + ]; + }); + + const service = new RestLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + dataFetcherResolver: () => fetcherSpy, + }); + + return service + .fetchPlaceholderData('superPh', '/xxx', 'da-DK') + .then((placeholderData: PlaceholderData) => { + expect(placeholderData).to.deep.equal({ + name: 'x1', + path: 'x1/x2', + elements: [], + }); + + expect(fetcherSpy).to.be.called.once; + expect(fetcherSpy).to.be.called.with( + 'http://sctest/sitecore/api/layout/placeholder/jss?placeholderName=superPh&item=%2Fxxx&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=da-DK&tracking=true' + ); + }); }); + }); - const service = new RestLayoutService({ - apiHost: 'http://sctest', - apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', - siteName: 'supersite', - dataFetcherResolver: () => fetcherSpy, + describe('GraphQLLayoutService', () => { + beforeEach(() => { + nock.cleanAll(); }); - return service - .fetchLayoutData('/home', 'da-DK') - .then((layoutServiceData: LayoutServiceData) => { - expect(layoutServiceData).to.deep.equal({ - sitecore: { - context: {}, - route: { name: 'xxx' }, + it('should fetch layout data', async () => { + nock('http://sctest') + .post('/graphql', (body) => { + return ( + body.query.replace(/\n|\s/g, '') === + 'query{layout(site:"supersite",routePath:"/styleguide",language:"da-DK"){item{rendered}}}' + ); + }) + .reply(200, { + data: { + layout: { + item: { + rendered: { + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + }, + route: { + name: 'styleguide', + layoutId: 'xxx', + }, + }, + }, + }, + }, }, }); - expect(fetcherSpy).to.be.called.once; - expect(fetcherSpy).to.be.called.with( - 'http://sctest/sitecore/api/layout/render/jss?item=%2Fhome&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=da-DK&tracking=true' - ); + const service = new GraphQLLayoutService({ + endpoint: 'http://sctest/graphql', + siteName: 'supersite', }); - }); - it('should fetch placeholder data', () => { - mock.onGet().reply(() => { - return [ - 200, - { - name: 'x1', - path: 'x1/x2', - elements: [], - }, - { - 'set-cookie': 'test-set-cookie-value', - }, - ]; - }); + const data = await service.fetchLayoutData('/styleguide', 'da-DK'); - const req = { - connection: { - remoteAddress: '192.168.1.10', - }, - headers: { - cookie: 'test-cookie-value', - referer: 'http://sctest', - 'user-agent': 'test-user-agent-value', - }, - } as IncomingMessage; - - const setHeaderSpy: any = spy(); - - const res = { - setHeader: setHeaderSpy, - } as ServerResponse; - - const service = new RestLayoutService({ - apiHost: 'http://sctest', - apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', - siteName: 'supersite', - tracking: false, + expect(data).to.deep.equal({ + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + }, + route: { + name: 'styleguide', + layoutId: 'xxx', + }, + }, + }); }); - return service - .fetchPlaceholderData('superPh', '/xxx', 'da-DK', req, res) - .then((placeholderData: PlaceholderData) => { - expect(placeholderData).to.deep.equal({ - name: 'x1', - path: 'x1/x2', - elements: [], + it('should fetch layout data if locale is not provided', async () => { + nock('http://sctest') + .post('/graphql', (body) => { + return ( + body.query.replace(/\n|\s/g, '') === + 'query{layout(site:"supersite",routePath:"/styleguide"){item{rendered}}}' + ); + }) + .reply(200, { + data: { + layout: { + item: { + rendered: { + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + }, + route: { + name: 'styleguide', + layoutId: 'xxx', + }, + }, + }, + }, + }, + }, }); - expect(setHeaderSpy).to.be.called.with('set-cookie', 'test-set-cookie-value'); + + const service = new GraphQLLayoutService({ + endpoint: 'http://sctest/graphql', + siteName: 'supersite', }); - }); - it('should fetch placeholder data using custom fetcher resolver', () => { - const fetcherSpy = spy((url: string) => { - return new AxiosDataFetcher().fetch(url); - }); + const data = await service.fetchLayoutData('/styleguide'); - mock.onGet().reply(() => { - return [ - 200, - { - name: 'x1', - path: 'x1/x2', - elements: [], + expect(data).to.deep.equal({ + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + }, + route: { + name: 'styleguide', + layoutId: 'xxx', + }, }, - ]; + }); }); - const service = new RestLayoutService({ - apiHost: 'http://sctest', - apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', - siteName: 'supersite', - dataFetcherResolver: () => fetcherSpy, + it('should fetch layout data using custom layout query', async () => { + nock('http://sctest') + .post('/graphql', (body) => { + return ( + body.query.replace(/\n|\s/g, '') === + 'query{layout111(site:"supersite",route:"/styleguide",language:"en"){item{rendered}}}' + ); + }) + .reply(200, { + data: { + layout: { + item: { + rendered: { + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + }, + route: { + name: 'styleguide', + layoutId: 'xxx', + }, + }, + }, + }, + }, + }, + }); + + const service = new GraphQLLayoutService({ + endpoint: 'http://sctest/graphql', + siteName: 'supersite', + formatLayoutQuery: (siteName, itemPath, locale) => + `layout111(site:"${siteName}",route:"${itemPath}",language:"${locale || 'en'}")`, + }); + + const data = await service.fetchLayoutData('/styleguide'); + + expect(data).to.deep.equal({ + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + }, + route: { + name: 'styleguide', + layoutId: 'xxx', + }, + }, + }); }); - return service - .fetchPlaceholderData('superPh', '/xxx', 'da-DK') - .then((placeholderData: PlaceholderData) => { - expect(placeholderData).to.deep.equal({ - name: 'x1', - path: 'x1/x2', - elements: [], + it('should return error', async () => { + nock('http://sctest') + .post('/graphql') + .reply(401, { + error: 'whoops', }); - expect(fetcherSpy).to.be.called.once; - expect(fetcherSpy).to.be.called.with( - 'http://sctest/sitecore/api/layout/placeholder/jss?placeholderName=superPh&item=%2Fxxx&sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&sc_site=supersite&sc_lang=da-DK&tracking=true' - ); + const service = new GraphQLLayoutService({ + endpoint: 'http://sctest/graphql', + siteName: 'supersite', }); + + await service.fetchLayoutData('/styleguide', 'da-DK').catch((error) => { + expect(error.response.status).to.equal(401); + expect(error.response.error).to.equal('whoops'); + }); + }); }); }); diff --git a/packages/sitecore-jss/src/layout-service.ts b/packages/sitecore-jss/src/layout-service.ts index a98f883aea..c76d0af7ba 100644 --- a/packages/sitecore-jss/src/layout-service.ts +++ b/packages/sitecore-jss/src/layout-service.ts @@ -1,9 +1,10 @@ +import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { IncomingMessage, ServerResponse } from 'http'; import { AxiosDataFetcher, AxiosDataFetcherConfig } from './data-fetcher'; import { LayoutServiceData, PlaceholderData } from './dataModels'; import { fetchPlaceholderData, fetchRouteData, LayoutServiceConfig } from './dataApi'; import { HttpJsonFetcher } from './httpClientInterface'; -import { AxiosRequestConfig, AxiosResponse } from 'axios'; -import { IncomingMessage, ServerResponse } from 'http'; +import { GraphQLRequestClient } from './graphql-request-client'; export interface LayoutService { /** @@ -58,6 +59,29 @@ export type RestLayoutServiceConfig = { dataFetcherResolver?: DataFetcherResolver; }; +export type GraphQLLayoutServiceConfig = { + /** + * Your Graphql endpoint + */ + endpoint: string; + /** + * The JSS application name + */ + siteName: string; + /** + * Override default layout query + * @param {string} siteName + * @param {string} itemPath + * @param {string} [locale] + * @returns {string} custom layout query + * + * @default + * Layout query + * layout(site:"${siteName}", routePath:"${itemPath}", language:"${language}") + */ + formatLayoutQuery?: (siteName: string, itemPath: string, locale?: string) => string; +}; + interface FetchParams { [param: string]: string | number | boolean; sc_apikey: string; @@ -201,3 +225,57 @@ export class RestLayoutService implements LayoutService { }; } } + +export class GraphQLLayoutService implements LayoutService { + /** + * Fetch layout data using the Sitecore GraphQL endpoint. + * @param {GraphQLLayoutServiceConfig} serviceConfig + */ + constructor(private serviceConfig: GraphQLLayoutServiceConfig) {} + + /** + * Fetch layout data for an item. + * @param {string} itemPath + * @param {string} [language] + * @returns {Promise} layout service data + */ + async fetchLayoutData(itemPath: string, language?: string): Promise { + const query = this.getLayoutQuery(itemPath, language); + + const data = await this.createClient().request<{ + layout: { item: { rendered: LayoutServiceData } }; + }>(query); + + return data?.layout.item.rendered; + } + + /** + * Returns new graphql client instance + */ + private createClient(): GraphQLRequestClient { + const { endpoint } = this.serviceConfig; + + return new GraphQLRequestClient(endpoint); + } + + /** + * Returns GraphQL Layout query + * @param {string} itemPath page route + * @param {string} [language] language + */ + private getLayoutQuery(itemPath: string, language?: string) { + const languageVariable = language ? `, language:"${language}"` : ''; + + const layoutQuery = this.serviceConfig.formatLayoutQuery + ? this.serviceConfig.formatLayoutQuery(this.serviceConfig.siteName, itemPath, language) + : `layout(site:"${this.serviceConfig.siteName}", routePath:"${itemPath}"${languageVariable})`; + + return `query { + ${layoutQuery}{ + item { + rendered + } + } + }`; + } +}