Skip to content

Commit

Permalink
feat: implement content source maps
Browse files Browse the repository at this point in the history
  • Loading branch information
YvesRijckaert committed Dec 20, 2023
1 parent cb285f2 commit 470b124
Show file tree
Hide file tree
Showing 4 changed files with 347 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ cypress/reports
cypress.env.json
backstage_docs
vite.config.d.ts

.nx

# Logs
logs
Expand Down
3 changes: 3 additions & 0 deletions packages/live-preview-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
245 changes: 245 additions & 0 deletions packages/live-preview-sdk/src/__tests__/csm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
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;
};
};
};

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];
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',
},
});
});
});
});
98 changes: 98 additions & 0 deletions packages/live-preview-sdk/src/csm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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<string, { source: Source }>;

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 {
constructor() {}

private isUrlOrIsoDate(string: string): boolean {
//@TODO - implement check for ISO date or URL
return false;
}

private getHref(
source: Source,
entries: EntitySource[],
assets: EntitySource[],
spaces: string[],
environments: string[],
fields: string[],
locales: string[]
): string | null {
const isEntry = 'entry' in source;
const content = isEntry ? entries[source.entry] : assets[source.asset];
if (!content) return null;

const space = spaces[content.space];
const environment = environments[content.environment];
const contentId = content.id;
const field = fields[source.field];
const locale = locales[source.locale];

const basePath = `https://app.contentful.com/spaces/${space}/environments/${environment}`;
const path = isEntry ? 'entries' : 'assets';
return `${basePath}/${path}/${contentId}/?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;
let data = graphqlResponse.data;

for (let pointer in mappings) {
const { source } = mappings[pointer];
const href = this.getHref(source, entries, assets, spaces, environments, fields, locales);

if (href && jsonPointer.has(data, pointer)) {
let currentValue = jsonPointer.get(data, pointer);
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;
}
}

0 comments on commit 470b124

Please sign in to comment.