future(preview): add content source maps service (#24213)

* feat: add content source maps service

* chore: refactor to fp and async.pipe

* chore: use header instead of query param

* fix: ignore polymorphic relations

* chore: add error handling

* fix: arrays of relations and medias

* chore: marc feedback

* chore: use traverseEntity util

* fix: make backend unit test async

* chore: refactor types
This commit is contained in:
Rémi de Juvigny 2025-08-26 07:40:09 -04:00 committed by GitHub
parent acdb68cd97
commit bcd95cff43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 206 additions and 46 deletions

View File

@ -12,6 +12,8 @@ module.exports = [
useDefaults: true,
directives: {
'frame-src': ["'self'"], // URLs that will be loaded in an iframe (e.g. Content Preview)
// Needed to load the `@vercel/stega` lib on the dummy-preview page
'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
},
},
},

View File

@ -7,19 +7,10 @@ import { registerPreviewRoute } from './preview';
const config = {
locales: ['it', 'es', 'en', 'en-GB'],
};
const bootstrap = (app) => {
app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
name: 'PreviewButton',
Component: () => (
<Button onClick={() => window.alert('Not here, The preview is.')}>Preview</Button>
),
});
};
export default {
config,
register: (app) => {
registerPreviewRoute(app);
},
bootstrap,
};

View File

@ -1,7 +1,6 @@
import * as React from 'react';
import { useParams, useLoaderData, useRevalidator } from 'react-router-dom';
// @ts-ignore
import { Page, Layouts } from '@strapi/admin/strapi-admin';
import { Grid, Flex, Typography, JSONInput, Box } from '@strapi/design-system';
@ -130,7 +129,7 @@ const PreviewComponent = () => {
{key}
</Typography>
<Flex gap={3} direction="column" alignItems="start" tag="dd">
<Typography data-strapi-source={key}>
<Typography>
{typeof value === 'object' && value !== null
? JSON.stringify(value, null, 2)
: String(value)}

View File

@ -25,6 +25,7 @@ const previewLoader = async ({ params }) => {
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
'strapi-encode-source-maps': 'true',
};
const searchParams = new URLSearchParams({
locale,

View File

@ -4,6 +4,7 @@ declare global {
__strapi_previewCleanup?: () => void;
STRAPI_HIGHLIGHT_HOVER_COLOR?: string;
STRAPI_HIGHLIGHT_ACTIVE_COLOR?: string;
STRAPI_DISABLE_STEGA_DECODING?: boolean;
}
}
@ -21,6 +22,7 @@ const previewScript = (shouldRun = true) => {
const HIGHLIGHT_HOVER_COLOR = window.STRAPI_HIGHLIGHT_HOVER_COLOR ?? '#4945ff'; // dark primary500
const HIGHLIGHT_ACTIVE_COLOR = window.STRAPI_HIGHLIGHT_ACTIVE_COLOR ?? '#7b79ff'; // dark primary600
const DISABLE_STEGA_DECODING = window.STRAPI_DISABLE_STEGA_DECODING ?? false;
const SOURCE_ATTRIBUTE = 'data-strapi-source';
const OVERLAY_ID = 'strapi-preview-overlay';
const INTERNAL_EVENTS = {
@ -54,6 +56,36 @@ const previewScript = (shouldRun = true) => {
* Functionality pieces
* ---------------------------------------------------------------------------------------------*/
const setupStegaDecoding = async () => {
if (DISABLE_STEGA_DECODING) {
return;
}
const { vercelStegaDecode: stegaDecode } = await import(
// @ts-expect-error it's not a local dependency
// eslint-disable-next-line import/no-unresolved
'https://cdn.jsdelivr.net/npm/@vercel/stega@0.1.2/+esm'
);
const allElements = document.querySelectorAll('*');
Array.from(allElements).forEach((element) => {
const directTextContent = Array.from(element.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent || '')
.join('');
if (directTextContent) {
try {
const result = stegaDecode(directTextContent);
if (result) {
element.setAttribute(SOURCE_ATTRIBUTE, result.key);
}
} catch (error) {}
}
});
};
const createOverlaySystem = () => {
// Clean up before creating a new overlay so we can safely call previewScript multiple times
window.__strapi_previewCleanup?.();
@ -121,12 +153,12 @@ const previewScript = (shouldRun = true) => {
// Move hover detection to the underlying element
const mouseEnterHandler = () => {
if (!highlightManager.focusedHighlights.includes(highlight)) {
if (!focusedHighlights.includes(highlight)) {
highlight.style.outlineColor = HIGHLIGHT_HOVER_COLOR;
}
};
const mouseLeaveHandler = () => {
if (!highlightManager.focusedHighlights.includes(highlight)) {
if (!focusedHighlights.includes(highlight)) {
highlight.style.outlineColor = 'transparent';
}
};
@ -340,11 +372,13 @@ const previewScript = (shouldRun = true) => {
* Orchestration
* ---------------------------------------------------------------------------------------------*/
const overlay = createOverlaySystem();
const highlightManager = createHighlightManager(overlay);
const observers = setupObservers(highlightManager);
const eventHandlers = setupEventHandlers(highlightManager);
createCleanupSystem(overlay, observers, eventHandlers);
setupStegaDecoding().then(() => {
const overlay = createOverlaySystem();
const highlightManager = createHighlightManager(overlay);
const observers = setupObservers(highlightManager);
const eventHandlers = setupEventHandlers(highlightManager);
createCleanupSystem(overlay, observers, eventHandlers);
});
};
export { previewScript };

View File

@ -64,6 +64,7 @@
"@strapi/types": "5.23.0",
"@strapi/typescript-utils": "5.23.0",
"@strapi/utils": "5.23.0",
"@vercel/stega": "0.1.2",
"bcryptjs": "2.4.3",
"boxen": "5.1.2",
"chalk": "4.1.2",

View File

@ -32,6 +32,7 @@ import getNumberOfDynamicZones from './services/utils/dynamic-zones';
import getNumberOfConditionalFields from './services/utils/conditional-fields';
import { FeaturesService, createFeaturesService } from './services/features';
import { createDocumentService } from './services/document-service';
import { createContentSourceMapsService } from './services/content-source-maps';
import { coreStoreModel } from './services/core-store';
import { createConfigProvider } from './services/config';
@ -287,7 +288,8 @@ class Strapi extends Container implements Core.Strapi {
})
);
})
.add('reload', () => createReloader(this));
.add('reload', () => createReloader(this))
.add('content-source-maps', () => createContentSourceMapsService(this));
}
sendStartupTelemetry() {

View File

@ -2,7 +2,7 @@ import type { Schema } from '@strapi/types';
import * as transforms from '../transform';
describe('Transforms', () => {
test('v4 - using json api format', () => {
test('v4 - using json api format', async () => {
const contentType: Schema.ContentType = {
globalId: 'test',
kind: 'collectionType',
@ -62,7 +62,7 @@ describe('Transforms', () => {
} as any;
expect(
transforms.transformResponse(
await transforms.transformResponse(
{
id: 1,
documentId: 'abcd',
@ -133,40 +133,40 @@ describe('Transforms', () => {
});
});
test('Leaves nil values untouched', () => {
expect(transforms.transformResponse(undefined)).toBeUndefined();
expect(transforms.transformResponse(null)).toBeNull();
test('Leaves nil values untouched', async () => {
expect(await transforms.transformResponse(undefined)).toBeUndefined();
expect(await transforms.transformResponse(null)).toBeNull();
});
test('Throws if entry is not and object or an array of object', () => {
expect(() => transforms.transformResponse(0)).toThrow();
expect(() => transforms.transformResponse(new Date())).toThrow();
expect(() => transforms.transformResponse('azaz')).toThrow();
test('Throws if entry is not and object or an array of object', async () => {
await expect(() => transforms.transformResponse(0)).rejects.toThrow();
await expect(() => transforms.transformResponse(new Date())).rejects.toThrow();
await expect(() => transforms.transformResponse('azaz')).rejects.toThrow();
});
test('Handles arrays of entries', () => {
expect(transforms.transformResponse([{ id: 1, title: 'Hello' }])).toStrictEqual({
test('Handles arrays of entries', async () => {
expect(await transforms.transformResponse([{ id: 1, title: 'Hello' }])).toStrictEqual({
data: [{ id: 1, title: 'Hello' }],
meta: {},
});
});
test('Handles single entry', () => {
expect(transforms.transformResponse({ id: 1, title: 'Hello' })).toStrictEqual({
test('Handles single entry', async () => {
expect(await transforms.transformResponse({ id: 1, title: 'Hello' })).toStrictEqual({
data: { id: 1, title: 'Hello' },
meta: {},
});
});
test('Accepts any meta', () => {
test('Accepts any meta', async () => {
const someMeta = { foo: 'bar' };
expect(transforms.transformResponse({ id: 1, title: 'Hello' }, someMeta)).toStrictEqual({
expect(await transforms.transformResponse({ id: 1, title: 'Hello' }, someMeta)).toStrictEqual({
data: { id: 1, title: 'Hello' },
meta: someMeta,
});
});
test('Handles relations single value', () => {
test('Handles relations single value', async () => {
const contentType: Schema.ContentType = {
globalId: 'test',
kind: 'collectionType',
@ -194,7 +194,7 @@ describe('Transforms', () => {
} as any;
expect(
transforms.transformResponse(
await transforms.transformResponse(
{ id: 1, title: 'Hello', relation: { id: 1, value: 'test' } },
undefined,
{ contentType }
@ -212,7 +212,7 @@ describe('Transforms', () => {
});
});
test('Handles relations array value', () => {
test('Handles relations array value', async () => {
const contentType: Schema.ContentType = {
globalId: 'test',
kind: 'collectionType',
@ -240,7 +240,7 @@ describe('Transforms', () => {
} as any;
expect(
transforms.transformResponse(
await transforms.transformResponse(
{ id: 1, title: 'Hello', relation: [{ id: 1, value: 'test' }] },
undefined,
{ contentType }
@ -260,7 +260,7 @@ describe('Transforms', () => {
});
});
test('Handles relations recursively', () => {
test('Handles relations recursively', async () => {
const contentType: Schema.ContentType = {
globalId: 'test',
kind: 'collectionType',
@ -295,7 +295,7 @@ describe('Transforms', () => {
} as any;
expect(
transforms.transformResponse(
await transforms.transformResponse(
{
id: 1,
title: 'Hello',
@ -323,7 +323,7 @@ describe('Transforms', () => {
});
});
test('Handles media like relations', () => {
test('Handles media like relations', async () => {
const contentType: Schema.ContentType = {
globalId: 'test',
kind: 'collectionType',
@ -349,7 +349,7 @@ describe('Transforms', () => {
} as any;
expect(
transforms.transformResponse(
await transforms.transformResponse(
{ id: 1, title: 'Hello', media: [{ id: 1, value: 'test' }] },
undefined,
{ contentType }
@ -369,7 +369,7 @@ describe('Transforms', () => {
});
});
test('Handles components & dynamic zones', () => {
test('Handles components & dynamic zones', async () => {
const contentType: Schema.ContentType = {
globalId: 'test',
kind: 'collectionType',
@ -409,7 +409,7 @@ describe('Transforms', () => {
} as any;
expect(
transforms.transformResponse(
await transforms.transformResponse(
{
id: 1,
title: 'Hello',

View File

@ -32,6 +32,7 @@ function createController({
return transformResponse(data, meta, {
contentType,
useJsonAPIFormat: ctx?.headers?.['strapi-response-format'] === 'v4',
encodeSourceMaps: ctx?.headers?.['strapi-encode-source-maps'] === 'true',
});
},

View File

@ -1,5 +1,6 @@
import { isNil, isPlainObject } from 'lodash/fp';
import type { UID, Struct, Data } from '@strapi/types';
import { async } from '@strapi/utils';
type TransformedEntry = {
id: string;
@ -32,13 +33,15 @@ interface TransformOptions {
* @deprecated this option is deprecated and will be removed in the next major version
*/
useJsonAPIFormat?: boolean;
encodeSourceMaps?: boolean;
}
const transformResponse = (
const transformResponse = async (
resource: any,
meta: unknown = {},
opts: TransformOptions = {
useJsonAPIFormat: false,
encodeSourceMaps: false,
}
) => {
if (isNil(resource)) {
@ -49,8 +52,20 @@ const transformResponse = (
throw new Error('Entry must be an object or an array of objects');
}
// Transform pipeline functions
const applyJsonApiFormat = async (data: any) =>
opts.useJsonAPIFormat ? transformEntry(data, opts?.contentType) : data;
const applySourceMapEncoding = async (data: any) =>
opts.encodeSourceMaps && opts.contentType
? strapi.get('content-source-maps').encodeSourceMaps({ data, schema: opts.contentType })
: data;
// Process data through transformation pipeline
const data = await async.pipe(applyJsonApiFormat, applySourceMapEncoding)(resource);
return {
data: opts.useJsonAPIFormat ? transformEntry(resource, opts?.contentType) : resource,
data,
meta,
};
};

View File

@ -0,0 +1,102 @@
import { vercelStegaCombine } from '@vercel/stega';
import type { Core, Struct, UID } from '@strapi/types';
import { traverseEntity } from '@strapi/utils';
const ENCODABLE_TYPES = [
'string',
'text',
'richtext',
'biginteger',
'date',
'time',
'datetime',
'timestamp',
'boolean',
'enumeration',
'json',
'media',
'email',
'password',
'uid',
/**
* We cannot modify the response shape, so types that aren't based on string cannot be encoded:
* - json: object
* - blocks: object, will require a custom implementation in a dedicated PR
* - integer, float and decimal: number
* - boolean: boolean (believe it or not)
*/
];
// TODO: use a centralized store for these fields that would be shared with the CM and CTB
const EXCLUDED_FIELDS = [
'id',
'documentId',
'locale',
'localizations',
'created_by',
'updated_by',
'created_at',
'updated_at',
'publishedAt',
];
interface EncodingInfo {
data: any;
schema: Struct.Schema;
}
const createContentSourceMapsService = (strapi: Core.Strapi) => {
return {
encodeField(text: string, key: string): string {
const res = vercelStegaCombine(text, {
// TODO: smarter metadata than just the key
key,
});
return res;
},
async encodeEntry({ data, schema }: EncodingInfo): Promise<any> {
if (typeof data !== 'object' || data === null || data === undefined) {
return data;
}
return traverseEntity(
({ key, value, attribute }, { set }) => {
if (!attribute || EXCLUDED_FIELDS.includes(key)) {
return;
}
if (ENCODABLE_TYPES.includes(attribute.type) && typeof value === 'string') {
set(key, this.encodeField(value, key) as any);
}
},
{
schema,
getModel: (uid) => strapi.getModel(uid as UID.Schema),
},
data
);
},
async encodeSourceMaps({ data, schema }: EncodingInfo): Promise<any> {
try {
if (Array.isArray(data)) {
return await Promise.all(
data.map((item) => this.encodeSourceMaps({ data: item, schema }))
);
}
if (typeof data !== 'object' || data === null) {
return data;
}
return await this.encodeEntry({ data, schema });
} catch (error) {
strapi.log.error('Error encoding source maps:', error);
return data;
}
},
};
};
export { createContentSourceMapsService };

View File

@ -24,6 +24,10 @@ export default (auth: unknown): Visitor =>
const handleMorphRelation = async () => {
const elements: any = (data as Record<string, MorphArray>)[key];
if (!elements) {
return;
}
if ('connect' in elements || 'set' in elements || 'disconnect' in elements) {
const newValue: Record<string, unknown> = {};

View File

@ -8955,6 +8955,7 @@ __metadata:
"@types/node": "npm:18.19.24"
"@types/node-schedule": "npm:2.1.7"
"@types/statuses": "npm:2.0.1"
"@vercel/stega": "npm:0.1.2"
bcryptjs: "npm:2.4.3"
boxen: "npm:5.1.2"
chalk: "npm:4.1.2"
@ -12071,6 +12072,13 @@ __metadata:
languageName: node
linkType: hard
"@vercel/stega@npm:0.1.2":
version: 0.1.2
resolution: "@vercel/stega@npm:0.1.2"
checksum: 10c0/66eb80f286d46806004a2eacd215af80ad3cd443e7a96a6070d390dbb3f4d1f6e063013ec0d196c7fe852721d5dbe62e23b44c32975e36b18cda30e5e0728e04
languageName: node
linkType: hard
"@vitejs/plugin-react-swc@npm:3.6.0":
version: 3.6.0
resolution: "@vitejs/plugin-react-swc@npm:3.6.0"