mirror of
https://github.com/strapi/strapi.git
synced 2026-01-07 04:33:46 +00:00
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:
parent
acdb68cd97
commit
bcd95cff43
@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
102
packages/core/core/src/services/content-source-maps.ts
Normal file
102
packages/core/core/src/services/content-source-maps.ts
Normal 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 };
|
||||
@ -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> = {};
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user