Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/add-zip-file-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tokens-studio/figma-plugin": patch
---

Add support for importing tokens from ZIP files. Users can now choose a ZIP file containing JSON token files in addition to individual files or folders.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default function FilePreset({ onCancel }: Props) {
const isProUser = useIsProUser();
const hiddenFileInput = React.useRef<HTMLInputElement>(null);
const hiddenDirectoryInput = React.useRef<HTMLInputElement>(null);
const hiddenZipInput = React.useRef<HTMLInputElement>(null);
const { fetchTokensFromFileOrDirectory } = useRemoteTokens();

const handleFileButtonClick = React.useCallback(() => {
Expand All @@ -49,6 +50,11 @@ export default function FilePreset({ onCancel }: Props) {
hiddenDirectoryInput.current?.click();
}, [hiddenDirectoryInput]);

const handleZipButtonClick = React.useCallback(() => {
track('Import', { type: 'zip' });
hiddenZipInput.current?.click();
}, [hiddenZipInput]);

const handleFileOrDirectoryChange = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;

Expand All @@ -63,7 +69,7 @@ export default function FilePreset({ onCancel }: Props) {
Import your existing tokens JSON files into the plugin.
</Heading>
<Text>
If you&lsquo;re using a single file, the first-level keys should be the token set names. If you&lsquo;re using multiple files, the file name / path are the set names.
If you&lsquo;re using a single file, the first-level keys should be the token set names. If you&lsquo;re using multiple files or a ZIP archive, the file name / path are the set names.
</Text>
</Stack>
<Stack direction="row" gap={3} justify="end">
Expand Down Expand Up @@ -94,6 +100,19 @@ export default function FilePreset({ onCancel }: Props) {
onChange={handleFileOrDirectoryChange}
webkitdirectory=""
/>
<Button
variant="primary"
onClick={handleZipButtonClick}
>
Choose ZIP
</Button>
<input
type="file"
ref={hiddenZipInput}
style={{ display: 'none' }}
onChange={handleFileOrDirectoryChange}
accept=".zip"
/>
</Stack>
</Stack>
);
Expand Down
73 changes: 73 additions & 0 deletions packages/tokens-studio-for-figma/src/storage/FileTokenStorage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import compact from 'just-compact';
import JSZip from 'jszip';
import {
RemoteTokenStorage, RemoteTokenstorageErrorMessage, RemoteTokenStorageFile, RemoteTokenStorageMetadata, RemoteTokenStorageSingleTokenSetFile, RemoteTokenStorageThemesFile,
} from './RemoteTokenStorage';
Expand Down Expand Up @@ -31,8 +32,80 @@ export class FileTokenStorage extends RemoteTokenStorage<unknown, SaveOption> {
return this;
}

private async readZipFile(zipFile: File): Promise<RemoteTokenStorageFile[] | RemoteTokenstorageErrorMessage> {
try {
const zip = new JSZip();
const content = await zip.loadAsync(zipFile);
const jsonFiles: { name: string; content: string }[] = [];

// Extract all JSON files from the ZIP
const filePromises = Object.keys(content.files).map(async (fileName) => {
const file = content.files[fileName];
if (!file.dir && fileName.endsWith('.json')) {
const fileContent = await file.async('text');
return { name: fileName, content: fileContent };
}
return null;
});

const extractedFiles = await Promise.all(filePromises);
jsonFiles.push(...extractedFiles.filter((f): f is { name: string; content: string } => f !== null));

// Sort files for consistent processing
jsonFiles.sort((a, b) => a.name.localeCompare(b.name));

// Process extracted files
const parsedFiles: RemoteTokenStorageFile[] = [];

for (const jsonFile of jsonFiles) {
if (jsonFile.content && IsJSONString(jsonFile.content)) {
const parsedJsonData = JSON.parse(jsonFile.content);
const validationResult = await multiFileSchema.safeParseAsync(parsedJsonData);

if (validationResult.success) {
const name = jsonFile.name.replace('.json', '');
const baseName = name.includes('/') ? name.substring(name.lastIndexOf('/') + 1) : name;

if (baseName === SystemFilenames.THEMES && Array.isArray(validationResult.data)) {
parsedFiles.push({
path: jsonFile.name,
type: 'themes',
data: validationResult.data,
} as RemoteTokenStorageThemesFile);
} else if (baseName === SystemFilenames.METADATA) {
parsedFiles.push({
path: jsonFile.name,
type: 'metadata',
data: validationResult.data as RemoteTokenStorageMetadata,
});
} else if (!Array.isArray(validationResult.data)) {
parsedFiles.push({
path: jsonFile.name,
name: baseName,
type: 'tokenSet',
data: validationResult.data,
} as RemoteTokenStorageSingleTokenSetFile);
}
}
}
}

return parsedFiles;
} catch (e) {
console.log('Error reading ZIP file:', e);
return {
errorMessage: ErrorMessages.FILE_CREDENTIAL_ERROR,
};
}
}

public async read(): Promise<RemoteTokenStorageFile[] | RemoteTokenstorageErrorMessage> {
try {
// Check if the file is a ZIP file
if (this.files.length === 1 && this.files[0].name.endsWith('.zip')) {
return this.readZipFile(this.files[0]);
}

if (this.flags.multiFileEnabled && this.files.length > 1) {
const jsonFiles = Array.from(this.files).filter((file) => file.webkitRelativePath.endsWith('.json'))
.sort((a, b) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import JSZip from 'jszip';
import { FileTokenStorage } from '../FileTokenStorage';

const json = JSON.stringify({
Expand Down Expand Up @@ -31,6 +32,35 @@ const json = JSON.stringify({
const blob = new Blob([json], { type: 'application/json' });
const mockFile = new File([blob], 'core.json');

const globalTokens = JSON.stringify({
primary: {
value: '1.5',
type: 'sizing',
},
secondary: {
value: '4',
type: 'sizing',
},
});

const themesJson = JSON.stringify([
{
id: '8722635276827d42671ab23df835867c9e0024dd',
name: 'Light',
group: 'Color',
selectedTokenSets: {
global: 'enabled',
},
$figmaStyleReferences: {},
},
]);

const metadataJson = JSON.stringify({
tokenSetOrder: [
'global',
],
});

describe('FileTokenStorage', () => {
it('should be able to read file', async () => {
const mockFileList = {
Expand Down Expand Up @@ -58,4 +88,48 @@ describe('FileTokenStorage', () => {
data: { primary: { type: 'sizing', value: '1.5' }, secondary: { type: 'sizing', value: '4' } }, name: 'global', path: 'core.json', type: 'tokenSet',
}]);
});

it('should be able to read ZIP file with multiple token files', async () => {
const zip = new JSZip();
zip.file('global.json', globalTokens);
zip.file('$themes.json', themesJson);
zip.file('$metadata.json', metadataJson);

const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFile = new File([zipBlob], 'tokens.zip');

const mockFileList = {
0: zipFile,
length: 1,
} as unknown as FileList;
const mockFileTokenStorage = new FileTokenStorage(mockFileList);

const result = await mockFileTokenStorage.read();

expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'metadata',
data: { tokenSetOrder: ['global'] },
}),
expect.objectContaining({
type: 'themes',
data: [
{
$figmaStyleReferences: {},
id: '8722635276827d42671ab23df835867c9e0024dd',
name: 'Light',
group: 'Color',
selectedTokenSets: { global: 'enabled' },
},
],
}),
expect.objectContaining({
type: 'tokenSet',
name: 'global',
data: { primary: { type: 'sizing', value: '1.5' }, secondary: { type: 'sizing', value: '4' } },
}),
]),
);
});
});
Loading