Auto-generate app cover images from the app logo (#22011)
<img width="1388" height="858" alt="image" src="https://github.com/user-attachments/assets/59f16bf7-5908-4624-b3af-51416bbebba3" /> ## What When an app is built (`twenty build` / `twenty publish`), the SDK now generates a marketplace cover image and sets it as the app's screenshot, but only when the app declares a `logoUrl` and has no `screenshots`. The cover composites the app's logo and the Twenty logo over the branded halftone backdrop, matching the design reference. ## Why Most apps ship a logo but no screenshots, so their marketplace detail page had no hero visual. This gives them a polished cover for free, with no per-app design work. ## Notes for reviewers - Generation lives in the build path (`operations/build.ts`), not `buildManifest`, so `twenty dev` and the shared manifest builder are untouched. It is best-effort: on failure it logs a warning and the build continues. - The cover is written to `.twenty/output` and registered as a public asset + screenshot, so the existing copy/checksum/serve pipeline handles it unchanged. No app source files are modified. - Adds `sharp` as a runtime dependency of `twenty-sdk` (a build-time tool, like `esbuild`); it is not bundled into built apps. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22011?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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...');
|
||||
|
||||
@@ -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,
|
||||
|
||||
+109
@@ -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<string, unknown>,
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
+38
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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';
|
||||
@@ -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<string> => {
|
||||
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<Buffer> => {
|
||||
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 = [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" viewBox="0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}">`,
|
||||
`<image href="${backdropDataUri}" x="0" y="0" width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" preserveAspectRatio="none" />`,
|
||||
'<defs>',
|
||||
'<filter id="tileShadow" x="-40%" y="-40%" width="180%" height="180%">',
|
||||
'<feDropShadow dx="0" dy="6" stdDeviation="10" flood-color="#0b0b0f" flood-opacity="0.14" />',
|
||||
'</filter>',
|
||||
`<clipPath id="leftLogoClip"><rect x="${LEFT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="${TILE_RADIUS}" /></clipPath>`,
|
||||
'</defs>',
|
||||
'<g filter="url(#tileShadow)">',
|
||||
`<rect x="${LEFT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="${TILE_RADIUS}" fill="#ffffff" />`,
|
||||
`<rect x="${RIGHT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="${TILE_RADIUS}" fill="#000000" />`,
|
||||
'</g>',
|
||||
`<image href="${logoDataUri}" x="${LEFT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" preserveAspectRatio="xMidYMid slice" clip-path="url(#leftLogoClip)" />`,
|
||||
`<rect x="${LEFT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="${TILE_RADIUS}" fill="none" stroke="#000000" stroke-opacity="0.08" stroke-width="1" />`,
|
||||
`<g transform="translate(${RIGHT_TILE_X} ${TILE_Y}) scale(${TWENTY_MARK_SCALE})"><path d="${TWENTY_LOGO_MARK_PATH}" fill="#ffffff" /></g>`,
|
||||
`<line x1="${CENTER_X - CROSS_ARM}" y1="${CENTER_Y - CROSS_ARM}" x2="${CENTER_X + CROSS_ARM}" y2="${CENTER_Y + CROSS_ARM}" stroke="${CROSS_COLOR}" stroke-width="${CROSS_STROKE}" stroke-linecap="round" />`,
|
||||
`<line x1="${CENTER_X - CROSS_ARM}" y1="${CENTER_Y + CROSS_ARM}" x2="${CENTER_X + CROSS_ARM}" y2="${CENTER_Y - CROSS_ARM}" stroke="${CROSS_COLOR}" stroke-width="${CROSS_STROKE}" stroke-linecap="round" />`,
|
||||
'</svg>',
|
||||
].join('');
|
||||
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type GeneratedAsset = {
|
||||
relativePath: string;
|
||||
content: Buffer;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ASSETS_DIR } from 'twenty-shared/application';
|
||||
|
||||
export const GENERATED_COVER_PATH = `${ASSETS_DIR}/cover.generated.png`;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user