diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 6d16fed3a8..76bc39f907 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -86,6 +86,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "semver": "7.6.3", + "sharp": "^0.34.5", "tinyglobby": "^0.2.15", "twenty-client-sdk": "workspace:*", "typescript": "^5.9.3", diff --git a/packages/twenty-sdk/src/cli/operations/build.ts b/packages/twenty-sdk/src/cli/operations/build.ts index 99f3ef6ad1..b585bf18b5 100644 --- a/packages/twenty-sdk/src/cli/operations/build.ts +++ b/packages/twenty-sdk/src/cli/operations/build.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import path from 'path'; +import { applyGeneratedCover } from '@/cli/utilities/build/cover/apply-generated-cover'; import { buildApplication } from '@/cli/utilities/build/common/build-application'; import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin'; import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; @@ -40,18 +41,34 @@ const innerAppBuild = async ( }; } - const { manifest, filePaths } = manifestResult; + const { filePaths } = manifestResult; for (const warning of manifestResult.warnings) { onProgress?.(`⚠ ${warning}`); } + const { manifest, generatedAssets } = await applyGeneratedCover({ + appPath, + manifest: manifestResult.manifest, + }).catch((error) => { + onProgress?.( + `⚠ Skipped cover image generation: ${error instanceof Error ? error.message : String(error)}`, + ); + + return { manifest: manifestResult.manifest, generatedAssets: [] }; + }); + + if (generatedAssets.length > 0) { + onProgress?.('Generated cover image from logo'); + } + onProgress?.('Building application files...'); const buildResult = await buildApplication({ appPath, manifest, filePaths, + generatedAssets, }); onProgress?.('Running typecheck...'); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts index cd38cda79f..4ae37d6b83 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'path'; import { NODE_ESM_CJS_BANNER, @@ -8,6 +8,7 @@ import { } from 'twenty-shared/application'; import { FileFolder } from 'twenty-shared/types'; +import { type GeneratedAsset } from '@/cli/utilities/build/cover/generated-asset.type'; import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build'; import { LOGIC_FUNCTION_EXTERNAL_MODULES } from '@/cli/utilities/build/common/esbuild-watcher'; import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options'; @@ -27,6 +28,7 @@ export type AppBuildOptions = { appPath: string; manifest: Manifest; filePaths: EntityFilePaths; + generatedAssets?: GeneratedAsset[]; }; export type BuiltFileInfo = { @@ -121,9 +123,45 @@ export const buildApplication = async ( collectFileBuilt, }); + for (const generatedAsset of options.generatedAssets ?? []) { + await writeGeneratedAsset({ + appPath: options.appPath, + generatedAsset, + collectFileBuilt, + }); + } + return { builtFileInfos }; }; +const writeGeneratedAsset = async ({ + appPath, + generatedAsset, + collectFileBuilt, +}: { + appPath: string; + generatedAsset: GeneratedAsset; + collectFileBuilt: OnFileBuiltCallback; +}) => { + const builtPath = join(OUTPUT_DIR, generatedAsset.relativePath); + const absoluteBuiltPath = join(appPath, builtPath); + + await ensureDir(dirname(absoluteBuiltPath)); + await writeFile(absoluteBuiltPath, generatedAsset.content); + + const checksum = crypto + .createHash('md5') + .update(generatedAsset.content) + .digest('hex'); + + collectFileBuilt({ + fileFolder: FileFolder.PublicAsset, + builtPath, + sourcePath: generatedAsset.relativePath, + checksum, + }); +}; + const copyStaticFiles = async ({ appPath, fileFolder, diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/apply-generated-cover.test.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/apply-generated-cover.test.ts new file mode 100644 index 0000000000..199dbdbf5f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/apply-generated-cover.test.ts @@ -0,0 +1,109 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type Manifest } from 'twenty-shared/application'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { applyGeneratedCover } from '@/cli/utilities/build/cover/apply-generated-cover'; +import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image'; +import { GENERATED_COVER_PATH } from '@/cli/utilities/build/cover/generated-cover-path'; + +vi.mock('@/cli/utilities/build/cover/generate-cover-image'); + +const mockedGenerateCoverImage = vi.mocked(generateCoverImage); + +const COVER_BUFFER = Buffer.from('generated-cover'); + +const buildManifest = ( + application: Record, + publicAssets: Manifest['publicAssets'] = [], +): Manifest => + ({ + application, + publicAssets, + }) as unknown as Manifest; + +describe('applyGeneratedCover', () => { + let appPath: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockedGenerateCoverImage.mockResolvedValue(COVER_BUFFER); + appPath = await mkdtemp(join(tmpdir(), 'cover-test-')); + }); + + afterEach(async () => { + await rm(appPath, { recursive: true, force: true }); + }); + + const writeLogo = async (relativePath: string) => { + await mkdir(join(appPath, 'public'), { recursive: true }); + await writeFile(join(appPath, relativePath), Buffer.from('logo')); + }; + + it('generates a cover when a local logo exists and no screenshots are set', async () => { + await writeLogo('public/logo.png'); + const manifest = buildManifest({ logoUrl: 'public/logo.png' }); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).toHaveBeenCalledTimes(1); + expect(result.manifest.application.screenshots).toEqual([ + GENERATED_COVER_PATH, + ]); + expect( + result.manifest.publicAssets.some( + (asset) => asset.filePath === GENERATED_COVER_PATH, + ), + ).toBe(true); + expect(result.generatedAssets).toEqual([ + { relativePath: GENERATED_COVER_PATH, content: COVER_BUFFER }, + ]); + }); + + it('does nothing when there is no logo', async () => { + const manifest = buildManifest({}); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).not.toHaveBeenCalled(); + expect(result.generatedAssets).toEqual([]); + expect(result.manifest.application.screenshots).toBeUndefined(); + }); + + it('does nothing when screenshots are already provided', async () => { + await writeLogo('public/logo.png'); + const manifest = buildManifest({ + logoUrl: 'public/logo.png', + screenshots: ['public/shot.png'], + }); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).not.toHaveBeenCalled(); + expect(result.generatedAssets).toEqual([]); + expect(result.manifest.application.screenshots).toEqual([ + 'public/shot.png', + ]); + }); + + it('does nothing when the logo is an absolute url', async () => { + const manifest = buildManifest({ + logoUrl: 'https://example.com/logo.png', + }); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).not.toHaveBeenCalled(); + expect(result.generatedAssets).toEqual([]); + }); + + it('does nothing when the logo file is missing', async () => { + const manifest = buildManifest({ logoUrl: 'public/missing.png' }); + + const result = await applyGeneratedCover({ appPath, manifest }); + + expect(mockedGenerateCoverImage).not.toHaveBeenCalled(); + expect(result.generatedAssets).toEqual([]); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/generate-cover-image.test.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/generate-cover-image.test.ts new file mode 100644 index 0000000000..6090edde31 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/__tests__/generate-cover-image.test.ts @@ -0,0 +1,38 @@ +import sharp from 'sharp'; +import { describe, expect, it } from 'vitest'; + +import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image'; + +const createLogoBuffer = () => + sharp({ + create: { + width: 200, + height: 200, + channels: 4, + background: { r: 108, g: 92, b: 224, alpha: 1 }, + }, + }) + .png() + .toBuffer(); + +describe('generateCoverImage', () => { + it('produces a 1600x1000 png from a logo buffer', async () => { + const logoBuffer = await createLogoBuffer(); + + const cover = await generateCoverImage({ logoBuffer }); + + const metadata = await sharp(cover).metadata(); + expect(metadata.format).toBe('png'); + expect(metadata.width).toBe(1388); + expect(metadata.height).toBe(858); + }); + + it('renders deterministically for the same logo', async () => { + const logoBuffer = await createLogoBuffer(); + + const first = await generateCoverImage({ logoBuffer }); + const second = await generateCoverImage({ logoBuffer }); + + expect(first.equals(second)).toBe(true); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/apply-generated-cover.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/apply-generated-cover.ts new file mode 100644 index 0000000000..3bc206f8a1 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/apply-generated-cover.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises'; +import { basename, extname, join } from 'path'; +import { type AssetManifest, type Manifest } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; + +import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image'; +import { type GeneratedAsset } from '@/cli/utilities/build/cover/generated-asset.type'; +import { GENERATED_COVER_PATH } from '@/cli/utilities/build/cover/generated-cover-path'; +import { pathExists } from '@/cli/utilities/file/fs-utils'; + +const isAbsoluteUrl = (url: string): boolean => + url.startsWith('http://') || url.startsWith('https://'); + +export const applyGeneratedCover = async ({ + appPath, + manifest, +}: { + appPath: string; + manifest: Manifest; +}): Promise<{ manifest: Manifest; generatedAssets: GeneratedAsset[] }> => { + const { logoUrl, screenshots } = manifest.application; + + if (!isDefined(logoUrl) || isAbsoluteUrl(logoUrl)) { + return { manifest, generatedAssets: [] }; + } + + if ((screenshots ?? []).length > 0) { + return { manifest, generatedAssets: [] }; + } + + const logoPath = join(appPath, logoUrl); + + if (!(await pathExists(logoPath))) { + return { manifest, generatedAssets: [] }; + } + + const logoBuffer = await readFile(logoPath); + const coverBuffer = await generateCoverImage({ logoBuffer }); + + const coverAsset: AssetManifest = { + filePath: GENERATED_COVER_PATH, + fileName: basename(GENERATED_COVER_PATH), + fileType: extname(GENERATED_COVER_PATH).replace(/^\./, ''), + checksum: null, + }; + + const publicAssets = [ + ...manifest.publicAssets.filter( + (asset) => asset.filePath !== GENERATED_COVER_PATH, + ), + coverAsset, + ]; + + return { + manifest: { + ...manifest, + application: { + ...manifest.application, + screenshots: [GENERATED_COVER_PATH], + }, + publicAssets, + }, + generatedAssets: [ + { relativePath: GENERATED_COVER_PATH, content: coverBuffer }, + ], + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/assets/halftone-backdrop.png b/packages/twenty-sdk/src/cli/utilities/build/cover/assets/halftone-backdrop.png new file mode 100644 index 0000000000..7b994a6e96 Binary files /dev/null and b/packages/twenty-sdk/src/cli/utilities/build/cover/assets/halftone-backdrop.png differ diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/assets/twenty-logo-mark-path.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/assets/twenty-logo-mark-path.ts new file mode 100644 index 0000000000..d4ba4a71ac --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/assets/twenty-logo-mark-path.ts @@ -0,0 +1,2 @@ +export const TWENTY_LOGO_MARK_PATH = + 'M6.822 14.174c0-2.435 1.996-4.411 4.456-4.411h8.574c.125 0 .241.075.293.19a.31.31 0 0 1-.056.344l-1.88 2.023c-.326.35-.787.552-1.27.552H11.3c-.738 0-1.338.594-1.338 1.325v3.336a.78.78 0 0 1-.783.777H7.61a.78.78 0 0 1-.783-.777v-3.36zM33.5 25.553c0 2.434-1.996 4.411-4.456 4.411h-3.642c-2.46 0-4.454-1.977-4.454-4.411v-6.315c0-.43.16-.842.456-1.16l2.124-2.285a.33.33 0 0 1 .355-.081.32.32 0 0 1 .205.295v9.527c0 .73.598 1.322 1.337 1.322h3.6a1.33 1.33 0 0 0 1.337-1.322V14.197c0-.73-.599-1.325-1.337-1.325H24.84c-.481 0-.938.201-1.265.547L11.088 26.856h7.503a.78.78 0 0 1 .784.778v1.552a.78.78 0 0 1-.784.778H8.481a1.655 1.655 0 0 1-1.662-1.644v-.824c0-.412.156-.809.44-1.114l13.999-15.06a4.9 4.9 0 0 1 3.594-1.56h4.189c2.46 0 4.454 1.977 4.454 4.412v11.379z'; diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/generate-cover-image.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/generate-cover-image.ts new file mode 100644 index 0000000000..be6be36a92 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/generate-cover-image.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import sharp from 'sharp'; + +import { TWENTY_LOGO_MARK_PATH } from '@/cli/utilities/build/cover/assets/twenty-logo-mark-path'; + +const readHalftoneBackdropDataUri = async (): Promise => { + const backdropBuffer = await readFile( + join(__dirname, 'assets', 'halftone-backdrop.png'), + ); + + return `data:image/png;base64,${backdropBuffer.toString('base64')}`; +}; + +const CANVAS_WIDTH = 1388; +const CANVAS_HEIGHT = 858; +const TILE_SIZE = 156; +const TILE_RADIUS = 16; +const TILE_Y = 351; +const LEFT_TILE_X = 434; +const RIGHT_TILE_X = 798; +const CENTER_X = CANVAS_WIDTH / 2; +const CENTER_Y = CANVAS_HEIGHT / 2; +const CROSS_ARM = 19; +const CROSS_STROKE = 8; +const CROSS_COLOR = '#b3b3b3'; +const TWENTY_MARK_VIEWBOX = 40; +const TWENTY_MARK_SCALE = TILE_SIZE / TWENTY_MARK_VIEWBOX; + +type GenerateCoverImageOptions = { + logoBuffer: Buffer; +}; + +export const generateCoverImage = async ({ + logoBuffer, +}: GenerateCoverImageOptions): Promise => { + const logoPngBuffer = await sharp(logoBuffer) + .resize(TILE_SIZE, TILE_SIZE, { fit: 'cover', position: 'centre' }) + .png() + .toBuffer(); + const logoDataUri = `data:image/png;base64,${logoPngBuffer.toString('base64')}`; + + const backdropDataUri = await readHalftoneBackdropDataUri(); + + const svg = [ + ``, + ``, + '', + '', + '', + '', + ``, + '', + '', + ``, + ``, + '', + ``, + ``, + ``, + ``, + ``, + '', + ].join(''); + + return sharp(Buffer.from(svg)).png().toBuffer(); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/generated-asset.type.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/generated-asset.type.ts new file mode 100644 index 0000000000..2222092a7e --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/generated-asset.type.ts @@ -0,0 +1,4 @@ +export type GeneratedAsset = { + relativePath: string; + content: Buffer; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/cover/generated-cover-path.ts b/packages/twenty-sdk/src/cli/utilities/build/cover/generated-cover-path.ts new file mode 100644 index 0000000000..df63407d2a --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/cover/generated-cover-path.ts @@ -0,0 +1,3 @@ +import { ASSETS_DIR } from 'twenty-shared/application'; + +export const GENERATED_COVER_PATH = `${ASSETS_DIR}/cover.generated.png`; diff --git a/packages/twenty-sdk/vite.config.node.ts b/packages/twenty-sdk/vite.config.node.ts index fca9a0783f..4ebb1f325e 100644 --- a/packages/twenty-sdk/vite.config.node.ts +++ b/packages/twenty-sdk/vite.config.node.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import path from 'path'; import { type PackageJson } from 'type-fest'; import { defineConfig } from 'vite'; @@ -5,6 +6,20 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import packageJson from './package.json'; +const copyCoverAssetsPlugin = () => ({ + name: 'copy-cover-assets', + closeBundle() { + const source = path.resolve( + __dirname, + 'src/cli/utilities/build/cover/assets/halftone-backdrop.png', + ); + const destinationDir = path.resolve(__dirname, 'dist/assets'); + + fs.mkdirSync(destinationDir, { recursive: true }); + fs.copyFileSync(source, path.join(destinationDir, 'halftone-backdrop.png')); + }, +}); + export default defineConfig(() => { return { root: __dirname, @@ -18,6 +33,7 @@ export default defineConfig(() => { tsconfigPaths({ root: __dirname, }), + copyCoverAssetsPlugin(), ], build: { emptyOutDir: false, diff --git a/yarn.lock b/yarn.lock index 0b294a3e9c..2083b245eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -53329,6 +53329,7 @@ __metadata: react-dom: "npm:^19.2.0" rollup-plugin-dts: "npm:^6.4.1" semver: "npm:7.6.3" + sharp: "npm:^0.34.5" tinyglobby: "npm:^0.2.15" ts-morph: "npm:^25.0.0" tsc-alias: "npm:^1.8.16"