diff --git a/.gitignore b/.gitignore index 823882af..e0470065 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ cypress/reports cypress.env.json backstage_docs vite.config.d.ts - +.nx # Logs logs diff --git a/packages/live-preview-sdk/package.json b/packages/live-preview-sdk/package.json index 2349b48a..4b56c302 100644 --- a/packages/live-preview-sdk/package.json +++ b/packages/live-preview-sdk/package.json @@ -51,7 +51,10 @@ "dependencies": { "@contentful/rich-text-types": "^16.2.0", "@contentful/visual-sdk": "^1.0.0-alpha.34", + "@types/json-pointer": "^1.0.34", + "@vercel/stega": "^0.1.0", "graphql-tag": "^2.12.6", + "json-pointer": "^0.6.2", "lodash.isequal": "^4.5.0" }, "devDependencies": { diff --git a/packages/live-preview-sdk/src/__tests__/csm.spec.ts b/packages/live-preview-sdk/src/__tests__/csm.spec.ts new file mode 100644 index 00000000..2d38a527 --- /dev/null +++ b/packages/live-preview-sdk/src/__tests__/csm.spec.ts @@ -0,0 +1,319 @@ +import { describe, test, beforeEach, expect } from 'vitest'; +import { ContentSourceMaps } from '../csm'; +import { vercelStegaDecode } from '@vercel/stega'; + +type Mappings = { + [key: string]: + | { + origin: string; + href: string; + } + | { + [nestedKey: string]: { + origin: string; + href: string; + }; + } + | undefined; +}; + +type EncodedResponse = + | { + [key: string]: string; + } + | Array<{ [key: string]: string }>; + +function testEncodingDecoding(encodedResponse: EncodedResponse, mappings: Mappings) { + if (Array.isArray(encodedResponse)) { + encodedResponse.forEach((item, index) => { + const itemMappings = mappings[index]; + if (!itemMappings) { + return; + } + for (const [key, expectedValue] of Object.entries(itemMappings)) { + const encodedValue = item[key]; + const decodedValue = vercelStegaDecode(encodedValue); + expect(decodedValue).toEqual(expectedValue); + } + }); + } else { + for (const [key, expectedValue] of Object.entries(mappings)) { + const encodedValue = encodedResponse[key]; + const decodedValue = vercelStegaDecode(encodedValue); + expect(decodedValue).toEqual(expectedValue); + } + } +} + +describe('Content Source Maps', () => { + let csm: ContentSourceMaps; + + beforeEach(() => { + csm = new ContentSourceMaps(); + }); + + describe('GraphQL', () => { + test('basic example', () => { + const graphQLResponse = { + data: { + post: { + title: 'Title of the post', + subtitle: 'Subtitle of the post', + }, + }, + extensions: { + contentSourceMaps: { + version: 1.0, + spaces: ['foo'], + environments: ['master'], + fields: ['title', 'subtitle'], + locales: ['en-US'], + entries: [{ space: 0, environment: 0, id: 'a1b2c3' }], + assets: [], + mappings: { + '/post/title': { + source: { + entry: 0, + field: 0, + locale: 0, + }, + }, + '/post/subtitle': { + source: { + entry: 0, + field: 1, + locale: 0, + }, + }, + }, + }, + }, + }; + const encodedGraphQLResponse = csm.encodeSourceMap(graphQLResponse); + testEncodingDecoding(encodedGraphQLResponse.data.post, { + title: { + origin: 'contentful.com', + href: 'https://app.contentful.com/spaces/foo/environments/master/entries/a1b2c3/?focusedField=title&focusedLocale=en-US', + }, + subtitle: { + origin: 'contentful.com', + href: 'https://app.contentful.com/spaces/foo/environments/master/entries/a1b2c3/?focusedField=subtitle&focusedLocale=en-US', + }, + }); + }); + + test('collections', () => { + const graphQLResponse = { + data: { + postCollection: { + items: [ + { + title: 'Title of the post', + }, + { + title: 'Title of the post 2', + }, + { + title: 'Title of the post 3', + }, + ], + }, + }, + extensions: { + contentSourceMaps: { + version: 1.0, + spaces: ['foo'], + environments: ['master'], + fields: ['title'], + locales: ['en-US'], + entries: [ + { space: 0, environment: 0, id: 'a1b2c3' }, + { space: 0, environment: 0, id: 'd4e5f6' }, + { space: 0, environment: 0, id: 'g7h8i9' }, + ], + assets: [], + mappings: { + '/postCollection/items/0/title': { + source: { + entry: 0, + field: 0, + locale: 0, + }, + }, + '/postCollection/items/1/title': { + source: { + entry: 1, + field: 0, + locale: 0, + }, + }, + '/postCollection/items/2/title': { + source: { + entry: 2, + field: 0, + locale: 0, + }, + }, + }, + }, + }, + }; + const encodedGraphQLResponse = csm.encodeSourceMap(graphQLResponse); + testEncodingDecoding(encodedGraphQLResponse.data.postCollection.items, { + 0: { + title: { + origin: 'contentful.com', + href: 'https://app.contentful.com/spaces/foo/environments/master/entries/a1b2c3/?focusedField=title&focusedLocale=en-US', + }, + }, + 1: { + title: { + origin: 'contentful.com', + href: 'https://app.contentful.com/spaces/foo/environments/master/entries/d4e5f6/?focusedField=title&focusedLocale=en-US', + }, + }, + 2: { + title: { + origin: 'contentful.com', + href: 'https://app.contentful.com/spaces/foo/environments/master/entries/g7h8i9/?focusedField=title&focusedLocale=en-US', + }, + }, + }); + }); + + test('aliasing with multiple locales', () => { + const graphQLResponse = { + data: { + postCollection: { + items: [ + { + akanTitle: 'Lorem', + aghemTitle: 'Ipsum', + spanishTitle: 'Dolor', + }, + ], + }, + }, + extensions: { + contentSourceMaps: { + version: 1.0, + spaces: ['foo'], + environments: ['master'], + fields: ['title'], + locales: ['ak', 'agq', 'es'], + entries: [{ space: 0, environment: 0, id: 'a1b2c3' }], + assets: [], + mappings: { + '/postCollection/items/0/akanTitle': { + source: { + entry: 0, + field: 0, + locale: 0, + }, + }, + '/postCollection/items/0/aghemTitle': { + source: { + entry: 0, + field: 0, + locale: 1, + }, + }, + '/postCollection/items/0/spanishTitle': { + source: { + entry: 0, + field: 0, + locale: 2, + }, + }, + }, + }, + }, + }; + const encodedGraphQLResponse = csm.encodeSourceMap(graphQLResponse); + testEncodingDecoding(encodedGraphQLResponse.data.postCollection.items[0], { + akanTitle: { + origin: 'contentful.com', + href: 'https://app.contentful.com/spaces/foo/environments/master/entries/a1b2c3/?focusedField=title&focusedLocale=ak', + }, + aghemTitle: { + origin: 'contentful.com', + href: 'https://app.contentful.com/spaces/foo/environments/master/entries/a1b2c3/?focusedField=title&focusedLocale=agq', + }, + spanishTitle: { + origin: 'contentful.com', + href: 'https://app.contentful.com/spaces/foo/environments/master/entries/a1b2c3/?focusedField=title&focusedLocale=es', + }, + }); + }); + + test('does not encode dates', () => { + const graphQLResponse = { + data: { + post: { + date: '2023-12-13T00:00:00.000+01:00', + }, + }, + extensions: { + contentSourceMaps: { + version: 1.0, + spaces: ['foo'], + environments: ['master'], + fields: ['date'], + locales: ['en-US'], + entries: [{ space: 0, environment: 0, id: 'a1b2c3' }], + assets: [], + mappings: { + '/post/date': { + source: { + entry: 0, + field: 0, + locale: 0, + }, + }, + }, + }, + }, + }; + const encodedGraphQLResponse = csm.encodeSourceMap(graphQLResponse); + + testEncodingDecoding(encodedGraphQLResponse.data.post, { + date: undefined, + }); + }); + + test('does not encode URLs', () => { + const graphQLResponse = { + data: { + post: { + url: 'https://test.com', + }, + }, + extensions: { + contentSourceMaps: { + version: 1.0, + spaces: ['foo'], + environments: ['master'], + fields: ['url'], + locales: ['en-US'], + entries: [{ space: 0, environment: 0, id: 'a1b2c3' }], + assets: [], + mappings: { + '/post/url': { + source: { + entry: 0, + field: 0, + locale: 0, + }, + }, + }, + }, + }, + }; + const encodedGraphQLResponse = csm.encodeSourceMap(graphQLResponse); + + testEncodingDecoding(encodedGraphQLResponse.data.post, { + url: undefined, + }); + }); + }); +}); diff --git a/packages/live-preview-sdk/src/csm/index.ts b/packages/live-preview-sdk/src/csm/index.ts new file mode 100644 index 00000000..deab32f4 --- /dev/null +++ b/packages/live-preview-sdk/src/csm/index.ts @@ -0,0 +1,104 @@ +import { vercelStegaEncode } from '@vercel/stega'; +import { debug } from '../helpers'; +import jsonPointer from 'json-pointer'; + +type Source = { + field: number; + locale: number; +} & ({ entry: number } | { asset: number }); + +interface EntitySource { + space: number; + environment: number; + id: string; +} + +type Mappings = Record; + +interface IContentSourceMaps { + version: number; + spaces: string[]; + environments: string[]; + fields: string[]; + locales: string[]; + entries: EntitySource[]; + assets: EntitySource[]; + mappings: Mappings; +} + +interface GraphQLResponse { + data: any; + extensions: { + contentSourceMaps: IContentSourceMaps; + }; +} + +export class ContentSourceMaps { + private isUrlOrIsoDate(value: string) { + // Regular expression for URL validation + const urlRegex = /^(http|https):\/\/[^ "]+$/; + // Regular expression for ISO 8601 date validation + const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?([+-]\d{2}:\d{2}|Z)?$/; + + // Check if the string matches URL or ISO 8601 date format + return urlRegex.test(value) || isoDateRegex.test(value); + } + + private getHref( + source: Source, + entries: EntitySource[], + assets: EntitySource[], + spaces: string[], + environments: string[], + fields: string[], + locales: string[] + ): string | null { + const isEntry = 'entry' in source; + const entity = isEntry ? entries[source.entry] : assets[source.asset]; + if (!entity) return null; + + const space = spaces[entity.space]; + const environment = environments[entity.environment]; + const entityId = entity.id; + const field = fields[source.field]; + const locale = locales[source.locale]; + + const basePath = `https://app.contentful.com/spaces/${space}/environments/${environment}`; + const entityType = isEntry ? 'entries' : 'assets'; + return `${basePath}/${entityType}/${entityId}/?focusedField=${field}&focusedLocale=${locale}`; + } + + encodeSourceMap(graphqlResponse: GraphQLResponse): GraphQLResponse { + if ( + !graphqlResponse || + !graphqlResponse.extensions || + !graphqlResponse.extensions.contentSourceMaps + ) { + debug.error('GraphQL response does not contain Content Source Maps information.'); + return graphqlResponse; + } + const { spaces, environments, fields, locales, entries, assets, mappings } = + graphqlResponse.extensions.contentSourceMaps; + const data = graphqlResponse.data; + + for (const pointer in mappings) { + const { source } = mappings[pointer]; + const href = this.getHref(source, entries, assets, spaces, environments, fields, locales); + + if (href && jsonPointer.has(data, pointer)) { + const currentValue = jsonPointer.get(data, pointer); + + if (!this.isUrlOrIsoDate(currentValue)) { + const encodedValue = vercelStegaEncode({ + origin: 'contentful.com', + href, + }); + jsonPointer.set(data, pointer, `${encodedValue}${currentValue}`); + } + } else { + debug.error(`Pointer ${pointer} not found in GraphQL data or href could not be generated.`); + } + } + return graphqlResponse; + } +}