Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: extract svg from design tools and add support in web platforms #103

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 47 additions & 42 deletions src/extractors/extractors/src/extractors/figma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {downloadStream, Registry} from '@diez/storage';
import {createWriteStream} from 'fs-extra';
import {join, relative} from 'path';
import {parse, URLSearchParams} from 'url';
import {OAuthable} from '../api';
import {ImageFormats, OAuthable} from '../api';
import {chunk, cliReporters, createFolders} from '../utils';
import {
getFigmaAccessToken,
Expand Down Expand Up @@ -192,10 +192,23 @@ const fetchFile = (id: string, authToken: string): Promise<FigmaFile> => {
return performGetRequestWithBearerToken<FigmaFile>(`${apiBase}/files/${id}`, authToken);
};

interface AssetDownloadParams {
scale: string;
ids: string;
}
const getImageUrls = async (fileId: string, authToken: string, format: string, scale: string, ids: string) => {
const params = new URLSearchParams([
['format', format],
['ids', ids],
['scale', scale],
]);

try {
const {images} = await performGetRequestWithBearerToken<FigmaImageResponse>(
`${apiBase}/images/${fileId}?${params.toString()}`, authToken);

return {format, scale, images: Object.entries(images)};
} catch (requestError) {
Log.warning('Figma failed to render some of your components to images, this generally happens when one or more of your components is too big. Diez will skip these images.');
return {format, scale, images: []};
}
};

const downloadAssets = async (
filenameMap: Map<string, string>,
Expand All @@ -208,50 +221,42 @@ const downloadAssets = async (
return;
}

Log.info('Retrieving component URLs from the Figma API...');
const componentIds = chunk(Array.from(filenameMap.keys()), importBatchSize);
const scales = ['1', '2', '3', '4'];
const streams: Promise<void>[] = [];
const componentIds = chunk(Array.from(filenameMap.keys()), importBatchSize);
const exportOptions = [
{
format: ImageFormats.png,
scales: ['1', '2', '3', '4'],
},
{
format: ImageFormats.svg,
scales: ['1'],
},
];

for (const chunkedIds of componentIds) {
const downloadParams: AssetDownloadParams[] = [];
const exports = [];
const ids = chunkedIds.join(',');

for (const scale of scales) {
downloadParams.push({scale, ids: chunkedIds.join(',')});
}
const resolvedUrlMap = new Map<string, Map<string, string>>(scales.map((scale) => [scale, new Map()]));
await Promise.all(downloadParams.map(async ({scale, ids}) => {
const params = new URLSearchParams([
['format', 'png'],
['ids', ids],
['scale', scale],
]);
const urlMap = resolvedUrlMap.get(scale)!;

try {
const {images} = await performGetRequestWithBearerToken<FigmaImageResponse>(
`${apiBase}/images/${fileId}?${params.toString()}`, authToken);
for (const [id, url] of Object.entries(images)) {
urlMap.set(id, url);
}
} catch (requestError) {
Log.warning('Figma failed to render some of your components to images, this generally happens when one or more of your components is too big. Diez will skip these images.');
for (const {format, scales} of exportOptions) {
for (const scale of scales) {
exports.push(getImageUrls(fileId, authToken, format, scale, ids));
}
}));

for (const [scale, urls] of resolvedUrlMap) {
for (const [id, url] of urls) {
if (!url) {
continue;
}
}

const filename = `${filenameMap.get(id)!}${scale !== '1' ? `@${scale}x` : ''}.png`;
for (const {format, scale, images} of await Promise.all(exports)) {
for (const [id, url] of images) {
const filename = `${filenameMap.get(id)!}${scale !== '1' ? `@${scale}x` : ''}.${format}`;
Log.info(`Downloading asset ${filename} from the Figma CDN...`);
streams.push(downloadStream(url).then((stream) => {
stream.pipe(createWriteStream(join(assetsDirectory, AssetFolder.Component, filename)));
}).catch(() => {
Log.warning(`Error downloading ${filename}`);
}));
streams.push(
downloadStream(url)
.then((stream) => {
stream.pipe(createWriteStream(join(assetsDirectory, AssetFolder.Component, filename)));
})
.catch(() => {
Log.warning(`Error downloading ${filename}`);
}),
);
}
}
}
Expand Down
20 changes: 17 additions & 3 deletions src/extractors/extractors/src/extractors/sketch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@diez/generation';
import {pathExists} from 'fs-extra';
import {basename, extname, join, relative} from 'path';
import {ImageFormats} from '../api';
import {cliReporters, createFolders, escapeShell} from '../utils';

const sketchExtension = '.sketch';
Expand All @@ -28,10 +29,23 @@ const sketchExtension = '.sketch';
*/
const runExportCommand = (sketchtoolPath: string, source: string, folder: string, out: string) => {
const output = escapeShell(join(out, folder));
const command =
`${sketchtoolPath} export --format=png --scales=1,2,3,4 --output=${output} ${folder} ${escapeShell(source)}`;

return execAsync(command);
const exportOptions = [
{
format: ImageFormats.svg,
scales: [1],
},
{
format: ImageFormats.png,
scales: [1, 2, 3, 4],
},
];

const commands = exportOptions
.map(({format, scales}) => `${sketchtoolPath} export --format=${format} --scales=${scales.join(',')} --output=${output} ${folder} ${escapeShell(source)}`)
.map((command) => execAsync(command));

return Promise.all(commands);
};

interface SketchColor {
Expand Down
9 changes: 9 additions & 0 deletions src/extractors/extractors/test/extractors/figma.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,15 @@ describe('Figma', () => {
},
expect.anything(),
);
expect(mockRequest).toHaveBeenNthCalledWith(
6,
{
headers: {Authorization: 'Bearer mock-token'},
json: true,
uri: 'https://api.figma.com/v1/images/key?format=svg&ids=legacyComponent%2Ccomponent&scale=1',
},
expect.anything(),
);

expect(mockCodegen).toHaveBeenCalledWith({
assets: new Map([[
Expand Down
4 changes: 3 additions & 1 deletion src/extractors/extractors/test/extractors/sketch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,11 @@ describe('Sketch', () => {
await writeFile('test.sketch', '');
await sketch.export({source: 'test.sketch', assets: 'out', code: 'src'}, '.');
expect(mockLocateBinaryMacOS).toHaveBeenCalledWith('com.bohemiancoding.sketch3');
expect(mockExec).toHaveBeenCalledTimes(2);
expect(mockExec).toHaveBeenCalledTimes(3);
expect(mockExec).toHaveBeenNthCalledWith(1, `${sketchtoolPath} dump test.sketch`, expect.anything());
expect(mockExec).toHaveBeenNthCalledWith(2,
`${sketchtoolPath} export --format=svg --scales=1 --output=out/Test.sketch.contents/slices slices test.sketch`);
expect(mockExec).toHaveBeenNthCalledWith(3,
`${sketchtoolPath} export --format=png --scales=1,2,3,4 --output=out/Test.sketch.contents/slices slices test.sketch`);
expect(mockCodegen).toHaveBeenCalled();
expect(mockCodegen).toHaveBeenCalledWith({
Expand Down
73 changes: 62 additions & 11 deletions src/framework/prefabs/src/image.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {prefab, Target} from '@diez/engine';
import {existsSync} from 'fs-extra';
import {File, FileType} from './file';
import {Size2D} from './size2d';

Expand All @@ -10,9 +11,44 @@ export interface ImageData {
file2x: File;
file3x: File;
file4x: File;
fileSvg: File;
size: Size2D;
}

enum SupportedImageExtensions {
Png = 'png',
Svg = 'svg',
}

const getImageFileData = (src: string) => {
const pathComponents = src.split('/');
const filename = pathComponents.pop() || '';
const extensionLocation = filename.lastIndexOf('.');
const dir = pathComponents.join('/');
const name = filename.slice(0, extensionLocation);
const ext = filename.slice(extensionLocation);
const extensions: Record<SupportedImageExtensions, boolean> = {
[SupportedImageExtensions.Png]: false,
[SupportedImageExtensions.Svg]: false,
};

if (!ext) {
for (const format of Object.values(SupportedImageExtensions)) {
extensions[format] = existsSync(`${src}.${format}`);
}
}

if (ext in SupportedImageExtensions) {
extensions[ext as SupportedImageExtensions] = true;
}

return {
dir,
name,
extensions,
};
};

/**
* Provides an abstraction for raster images. With bindings, this component can embed images in multiple platforms in
* accordance with best practices. Images should provide pixel ratios for standard, @2x, @3x, and @4x with conventional
Expand All @@ -39,33 +75,48 @@ export class Image extends prefab<ImageData>() {
* `image = Image.responsive('assets/filename.png', 640, 480);`
*/
static responsive (src: string, width: number = 0, height: number = 0) {
const pathComponents = src.split('/');
const filename = pathComponents.pop() || '';
const extensionLocation = filename.lastIndexOf('.');
const dir = pathComponents.join('/');
const name = filename.slice(0, extensionLocation);
const ext = filename.slice(extensionLocation);
return new Image({
const {dir, name, extensions} = getImageFileData(src);

let data: Partial<ImageData> = {
file: new File({src, type: FileType.Image}),
file2x: new File({src: `${dir}/${name}@2x${ext}`, type: FileType.Image}),
file3x: new File({src: `${dir}/${name}@3x${ext}`, type: FileType.Image}),
file4x: new File({src: `${dir}/${name}@4x${ext}`, type: FileType.Image}),
size: Size2D.make(width, height),
});
};

if (extensions[SupportedImageExtensions.Png]) {
data = {
...data,
file2x: new File({src: `${dir}/${name}@2x${SupportedImageExtensions.Png}`, type: FileType.Image}),
file3x: new File({src: `${dir}/${name}@3x${SupportedImageExtensions.Png}`, type: FileType.Image}),
file4x: new File({src: `${dir}/${name}@4x${SupportedImageExtensions.Png}`, type: FileType.Image}),
};
}

if (extensions[SupportedImageExtensions.Svg]) {
data = {
...data,
file2x: new File({src: `${dir}/${name}${SupportedImageExtensions.Svg}`, type: FileType.Image}),
};
}

return new Image(data);
}

defaults = {
file: new File({type: FileType.Image}),
file2x: new File({type: FileType.Image}),
file3x: new File({type: FileType.Image}),
file4x: new File({type: FileType.Image}),
fileSvg: new File({type: FileType.Image}),
size: Size2D.make(0, 0),
};

options = {
file4x: {
targets: [Target.Android],
},
fileSvg: {
targets: [Target.Web],
},
};

toPresentableValue () {
Expand Down