Skip to content

Commit af70ef7

Browse files
feat: implement Figma image caching and update guideline example to u… (#275)
* feat: implement Figma image caching and update guideline example to use cached images * feat: enhance guideline example with DBCard component and improve layout * auto update snapshots (#285) Co-authored-by: Nicolas Merget <104347736+nmerget@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent e5b8ecf commit af70ef7

File tree

217 files changed

+399
-30
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

217 files changed

+399
-30
lines changed

.env.template

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ COMMIT_MAIL=my.commit@mail.com
55
# https://marketingportal.extranet.deutschebahn.com/marketingportal/Design-Anwendungen/db-ux-design-system/resources/db-theme
66
ASSET_PASSWORD=XXX
77
ASSET_INIT_VECTOR=XXX
8+
9+
# Figma API token for caching guideline images (https://www.figma.com/developers/api#access-tokens)
10+
FIGMA_TOKEN=figd_XXX

.github/workflows/deploy-one-platform.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ jobs:
5050
env:
5151
ASSET_PASSWORD: ${{ secrets.ASSET_PASSWORD }}
5252
ASSET_INIT_VECTOR: ${{ secrets.ASSET_INIT_VECTOR }}
53+
FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
5354
run: |
5455
pnpm run decode-db-theme-assets
56+
if [ "${{ github.event_name }}" != "pull_request" ]; then pnpm run cache:figma; fi
5557
pnpm run build
5658
5759
- name: Playwright

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"lint": "pnpm run \"/^lint:.*/\"",
1616
"lint:eslint": "eslint \"**/*.{js,ts,tsx,astro}\"",
1717
"lint:stylelint": "stylelint \"**/*.css\" --ignore-path .stylelintignore",
18+
"cache:figma": "node scripts/cache-figma-images.ts",
1819
"decode-db-theme-assets": "node decode-db-assets.js",
1920
"test": "playwright test",
2021
"test:ui": "playwright test --ui",

scripts/cache-figma-images.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Figma Image Cache Script
3+
*
4+
* Scans all MDX files for figmaUrl props, exports node images via the Figma API,
5+
* downloads them as PNG, and writes a manifest JSON for build-time lookup.
6+
*
7+
* Usage: node scripts/cache-figma-images.ts
8+
* Requires: FIGMA_TOKEN in .env
9+
*/
10+
11+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
12+
import { join, resolve } from 'path';
13+
import { readdir } from 'fs/promises';
14+
import { loadEnvFile } from 'node:process';
15+
16+
interface FigmaParsed {
17+
fileKey: string;
18+
nodeId: string;
19+
}
20+
21+
interface CacheEntry {
22+
url: string;
23+
nodeId: string;
24+
filename: string;
25+
}
26+
27+
type Manifest = Record<string, string>;
28+
29+
const ROOT = resolve(import.meta.dirname, '..');
30+
const MDX_DIR = join(ROOT, 'content', 'pages', 'documentation', 'components');
31+
const CACHE_DIR = join(ROOT, 'static', 'assets', 'figma-cache');
32+
const MANIFEST_PATH = join(ROOT, 'static', 'assets', 'figma-cache', 'manifest.json');
33+
34+
loadEnvFile(join(ROOT, '.env'));
35+
const FIGMA_TOKEN = process.env.FIGMA_TOKEN;
36+
37+
if (!FIGMA_TOKEN) {
38+
console.error('❌ FIGMA_TOKEN not found. Add it to .env or set it as an environment variable.');
39+
process.exit(0);
40+
}
41+
42+
mkdirSync(CACHE_DIR, { recursive: true });
43+
44+
function parseFigmaUrl(url: string): FigmaParsed | null {
45+
const fileKeyMatch = url.match(/\/design\/([^/?]+)/);
46+
const nodeIdMatch = url.match(/[?&]node-id=([^&]+)/);
47+
if (!fileKeyMatch || !nodeIdMatch) return null;
48+
return { fileKey: fileKeyMatch[1], nodeId: nodeIdMatch[1] };
49+
}
50+
51+
function cacheFilename(fileKey: string, nodeId: string): string {
52+
return `${fileKey}_${nodeId.replace(/[:/]/g, '-')}.png`;
53+
}
54+
55+
async function collectFigmaUrls(): Promise<string[]> {
56+
const files = (await readdir(MDX_DIR)).filter((f) => f.endsWith('.mdx'));
57+
const urlSet = new Set<string>();
58+
for (const file of files) {
59+
const content = readFileSync(join(MDX_DIR, file), 'utf-8');
60+
const matches = content.matchAll(/figmaUrl="([^"]+)"/g);
61+
for (const [, url] of matches) {
62+
const base = url.split('?')[0];
63+
const nodeIdMatch = url.match(/[?&]node-id=([^&]+)/);
64+
if (nodeIdMatch) urlSet.add(`${base}?node-id=${nodeIdMatch[1]}&embed-host=share`);
65+
}
66+
}
67+
return [...urlSet];
68+
}
69+
70+
async function exportImages(fileKey: string, nodeIds: string[]): Promise<Record<string, string>> {
71+
// Figma API expects node IDs with `:` (e.g. 3:852), but embed URLs use `-` (e.g. 3-852)
72+
const ids = nodeIds.map((id) => id.replace('-', ':')).join(',');
73+
const res = await fetch(
74+
`https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(ids)}&format=png&scale=2`,
75+
{ headers: { 'X-Figma-Token': FIGMA_TOKEN! } },
76+
);
77+
if (!res.ok) throw new Error(`Figma API error ${res.status}: ${await res.text()}`);
78+
const data = await res.json();
79+
if (data.err) throw new Error(`Figma export error: ${data.err}`);
80+
return data.images;
81+
}
82+
83+
async function downloadImage(imageUrl: string, destPath: string): Promise<void> {
84+
const res = await fetch(imageUrl);
85+
if (!res.ok) throw new Error(`Download failed ${res.status}: ${imageUrl}`);
86+
const buffer = await res.arrayBuffer();
87+
writeFileSync(destPath, Buffer.from(buffer));
88+
}
89+
90+
async function main(): Promise<void> {
91+
const urls = await collectFigmaUrls();
92+
console.log(`Found ${urls.length} unique Figma URLs`);
93+
94+
const manifest: Manifest = existsSync(MANIFEST_PATH)
95+
? JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'))
96+
: {};
97+
98+
const byFileKey = new Map<string, CacheEntry[]>();
99+
for (const url of urls) {
100+
const parsed = parseFigmaUrl(url);
101+
if (!parsed) continue;
102+
const filename = cacheFilename(parsed.fileKey, parsed.nodeId);
103+
if (existsSync(join(CACHE_DIR, filename))) {
104+
if (!manifest[url]) manifest[url] = `/assets/figma-cache/${filename}`;
105+
continue;
106+
}
107+
if (!byFileKey.has(parsed.fileKey)) byFileKey.set(parsed.fileKey, []);
108+
byFileKey.get(parsed.fileKey)!.push({ url, nodeId: parsed.nodeId, filename });
109+
}
110+
111+
let fetched = 0;
112+
let failed = 0;
113+
114+
for (const [fileKey, entries] of byFileKey) {
115+
const nodeIds = entries.map((e) => e.nodeId);
116+
console.log(`Exporting ${nodeIds.length} nodes from file ${fileKey}…`);
117+
118+
let images: Record<string, string>;
119+
try {
120+
images = await exportImages(fileKey, nodeIds);
121+
} catch (err) {
122+
console.error(` ❌ Failed to export from ${fileKey}: ${(err as Error).message}`);
123+
failed += entries.length;
124+
continue;
125+
}
126+
127+
for (const { url, nodeId, filename } of entries) {
128+
const imageUrl = images[nodeId.replace('-', ':')];
129+
if (!imageUrl) {
130+
console.warn(` ⚠️ No image returned for node ${nodeId}`);
131+
failed++;
132+
continue;
133+
}
134+
try {
135+
await downloadImage(imageUrl, join(CACHE_DIR, filename));
136+
manifest[url] = `/assets/figma-cache/${filename}`;
137+
fetched++;
138+
console.log(` ✓ ${filename}`);
139+
} catch (err) {
140+
console.error(` ❌ ${filename}: ${(err as Error).message}`);
141+
failed++;
142+
}
143+
}
144+
}
145+
146+
writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
147+
console.log(`\nDone. ${fetched} fetched, ${failed} failed. Manifest: ${MANIFEST_PATH}`);
148+
}
149+
150+
main().catch((err) => {
151+
console.error(err);
152+
process.exit(1);
153+
});
36.1 KB
15.5 KB
32.8 KB
30.7 KB
7.81 KB
25.1 KB

0 commit comments

Comments
 (0)