inkhouse 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -0
- package/bin/inkhouse.mjs +171 -0
- package/code.js +11802 -0
- package/manifest.json +30 -0
- package/package.json +45 -0
- package/scanner/blob-placement-regression.ts +132 -0
- package/scanner/class-collector.ts +69 -0
- package/scanner/cli.ts +336 -0
- package/scanner/component-scanner.ts +2876 -0
- package/scanner/css-patch-regression.ts +112 -0
- package/scanner/css-token-reader-regression.ts +92 -0
- package/scanner/css-token-reader.ts +477 -0
- package/scanner/font-style-resolver-regression.ts +32 -0
- package/scanner/index.ts +9 -0
- package/scanner/radial-gradient-regression.ts +53 -0
- package/scanner/style-map.ts +145 -0
- package/scanner/tailwind-parser.ts +644 -0
- package/scanner/transform-math-regression.ts +42 -0
- package/scanner/types.ts +298 -0
- package/src/blob-placement.ts +111 -0
- package/src/change-detection.ts +204 -0
- package/src/class-utils.ts +105 -0
- package/src/clip-path-decorative.ts +194 -0
- package/src/color-resolver.ts +98 -0
- package/src/colors.ts +196 -0
- package/src/component-defs.ts +54 -0
- package/src/component-gen.ts +561 -0
- package/src/component-lookup.ts +82 -0
- package/src/config.ts +115 -0
- package/src/design-system.ts +59 -0
- package/src/dev-server.ts +173 -0
- package/src/figma-globals.d.ts +3 -0
- package/src/font-style-resolver.ts +171 -0
- package/src/github.ts +1465 -0
- package/src/icon-builder.ts +607 -0
- package/src/image-cache.ts +22 -0
- package/src/inline-text.ts +271 -0
- package/src/layout-parser.ts +667 -0
- package/src/layout-utils.ts +155 -0
- package/src/main.ts +687 -0
- package/src/node-ir.ts +595 -0
- package/src/pack-provider.ts +148 -0
- package/src/packs.ts +126 -0
- package/src/radial-gradient.ts +84 -0
- package/src/render-context.ts +138 -0
- package/src/responsive-analyzer.ts +139 -0
- package/src/state-analyzer.ts +143 -0
- package/src/story-builder.ts +1706 -0
- package/src/story-layout.ts +38 -0
- package/src/tailwind.ts +2379 -0
- package/src/text-builder.ts +116 -0
- package/src/text-line.ts +42 -0
- package/src/token-source.ts +43 -0
- package/src/tokens.ts +717 -0
- package/src/transform-math.ts +44 -0
- package/src/ui-builder.ts +1996 -0
- package/src/utility-resolver.ts +125 -0
- package/src/variables.ts +1042 -0
- package/src/width-solver.ts +466 -0
- package/templates/patch-tokens-route.ts +165 -0
- package/templates/scan-components-route.ts +57 -0
- package/ui.html +1222 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Figma Plugin API types are provided by the runtime
|
|
2
|
+
|
|
3
|
+
import type { TokenSourceMode } from './token-source';
|
|
4
|
+
|
|
5
|
+
export interface ProjectConfig {
|
|
6
|
+
owner: string;
|
|
7
|
+
repo: string;
|
|
8
|
+
baseBranch: string;
|
|
9
|
+
tokenPath: string;
|
|
10
|
+
tokenSourceMode: TokenSourceMode;
|
|
11
|
+
cssTokenPath: string;
|
|
12
|
+
syncDtcgOnPush: boolean;
|
|
13
|
+
allowNewTokensFromFigma: boolean;
|
|
14
|
+
newTokenPrefixes: string[];
|
|
15
|
+
projectName: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Default GitHub configuration (can be overridden via Settings)
|
|
19
|
+
export const DEFAULT_CONFIG: ProjectConfig = {
|
|
20
|
+
owner: '',
|
|
21
|
+
repo: '',
|
|
22
|
+
baseBranch: 'main',
|
|
23
|
+
tokenPath: 'design-tokens/tokens.dtcg.json',
|
|
24
|
+
tokenSourceMode: 'auto',
|
|
25
|
+
cssTokenPath: '',
|
|
26
|
+
syncDtcgOnPush: false,
|
|
27
|
+
allowNewTokensFromFigma: false,
|
|
28
|
+
newTokenPrefixes: [],
|
|
29
|
+
projectName: 'My Project',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Runtime config - loaded from storage
|
|
33
|
+
export let GITHUB_CONFIG: ProjectConfig | null = null;
|
|
34
|
+
|
|
35
|
+
function normalizeTokenPath(tokenPath: unknown): string {
|
|
36
|
+
if (typeof tokenPath !== 'string' || !tokenPath.trim()) {
|
|
37
|
+
return DEFAULT_CONFIG.tokenPath;
|
|
38
|
+
}
|
|
39
|
+
if (tokenPath === 'design-tokens/tokens.json') {
|
|
40
|
+
return DEFAULT_CONFIG.tokenPath;
|
|
41
|
+
}
|
|
42
|
+
return tokenPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeTokenSourceMode(mode: unknown): TokenSourceMode {
|
|
46
|
+
if (mode === 'css' || mode === 'dtcg' || mode === 'auto') return mode;
|
|
47
|
+
return 'auto';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeCssTokenPath(cssTokenPath: unknown): string {
|
|
51
|
+
if (typeof cssTokenPath !== 'string') return '';
|
|
52
|
+
return cssTokenPath.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeSyncDtcgOnPush(syncDtcgOnPush: unknown): boolean {
|
|
56
|
+
return syncDtcgOnPush === true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeAllowNewTokensFromFigma(value: unknown): boolean {
|
|
60
|
+
return value === true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeNewTokenPrefixes(value: unknown): string[] {
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
return value
|
|
66
|
+
.map((entry) => String(entry || '').trim())
|
|
67
|
+
.filter((entry) => entry.length > 0);
|
|
68
|
+
}
|
|
69
|
+
if (typeof value === 'string') {
|
|
70
|
+
return value
|
|
71
|
+
.split(',')
|
|
72
|
+
.map((entry) => entry.trim())
|
|
73
|
+
.filter((entry) => entry.length > 0);
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Load config from storage or use defaults
|
|
79
|
+
export async function loadConfig(): Promise<ProjectConfig> {
|
|
80
|
+
if (GITHUB_CONFIG) return GITHUB_CONFIG;
|
|
81
|
+
try {
|
|
82
|
+
const stored = await figma.clientStorage.getAsync('project_config');
|
|
83
|
+
if (stored && typeof stored === 'object') {
|
|
84
|
+
const storedConfig = stored as Partial<ProjectConfig>;
|
|
85
|
+
GITHUB_CONFIG = {
|
|
86
|
+
...DEFAULT_CONFIG,
|
|
87
|
+
...storedConfig,
|
|
88
|
+
tokenPath: normalizeTokenPath(storedConfig.tokenPath),
|
|
89
|
+
tokenSourceMode: normalizeTokenSourceMode(storedConfig.tokenSourceMode),
|
|
90
|
+
cssTokenPath: normalizeCssTokenPath(storedConfig.cssTokenPath),
|
|
91
|
+
syncDtcgOnPush: normalizeSyncDtcgOnPush(storedConfig.syncDtcgOnPush),
|
|
92
|
+
allowNewTokensFromFigma: normalizeAllowNewTokensFromFigma((storedConfig as any).allowNewTokensFromFigma),
|
|
93
|
+
newTokenPrefixes: normalizeNewTokenPrefixes((storedConfig as any).newTokenPrefixes),
|
|
94
|
+
};
|
|
95
|
+
// Persist one-time migrations so old installs stop carrying stale settings.
|
|
96
|
+
await figma.clientStorage.setAsync('project_config', GITHUB_CONFIG);
|
|
97
|
+
} else {
|
|
98
|
+
GITHUB_CONFIG = Object.assign({}, DEFAULT_CONFIG);
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
GITHUB_CONFIG = Object.assign({}, DEFAULT_CONFIG);
|
|
102
|
+
}
|
|
103
|
+
return GITHUB_CONFIG;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function saveConfig(config: ProjectConfig): Promise<void> {
|
|
107
|
+
config.tokenPath = normalizeTokenPath(config.tokenPath);
|
|
108
|
+
config.tokenSourceMode = normalizeTokenSourceMode((config as any).tokenSourceMode);
|
|
109
|
+
config.cssTokenPath = normalizeCssTokenPath((config as any).cssTokenPath);
|
|
110
|
+
config.syncDtcgOnPush = normalizeSyncDtcgOnPush((config as any).syncDtcgOnPush);
|
|
111
|
+
(config as any).allowNewTokensFromFigma = normalizeAllowNewTokensFromFigma((config as any).allowNewTokensFromFigma);
|
|
112
|
+
(config as any).newTokenPrefixes = normalizeNewTokenPrefixes((config as any).newTokenPrefixes);
|
|
113
|
+
GITHUB_CONFIG = config;
|
|
114
|
+
await figma.clientStorage.setAsync('project_config', config);
|
|
115
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Design System Page Generation
|
|
2
|
+
// Orchestrates building the full design system preview page in Figma.
|
|
3
|
+
|
|
4
|
+
import { debug } from './colors';
|
|
5
|
+
import { getThemeNames, TOKENS } from './tokens';
|
|
6
|
+
import { demoFrameColors, demoFrameRadii } from './variables';
|
|
7
|
+
import { createUIComponents } from './ui-builder';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build a single "Design System" page containing tokens preview and all UI components.
|
|
11
|
+
* Clears any existing content on the page before rebuilding.
|
|
12
|
+
*/
|
|
13
|
+
export function buildDesignSystemSinglePage(): void {
|
|
14
|
+
let ds: any = (figma.root as any).children.find((p: any) => p.name === 'Design System');
|
|
15
|
+
if (!ds) { ds = figma.createPage(); ds.name = 'Design System'; }
|
|
16
|
+
figma.currentPage = ds;
|
|
17
|
+
const nodes = Array.from(ds.children || []);
|
|
18
|
+
for (const n of nodes as any[]) n.remove();
|
|
19
|
+
|
|
20
|
+
const tokensRow = figma.createFrame();
|
|
21
|
+
tokensRow.name = 'Design Tokens';
|
|
22
|
+
tokensRow.layoutMode = 'HORIZONTAL';
|
|
23
|
+
tokensRow.primaryAxisSizingMode = 'AUTO';
|
|
24
|
+
tokensRow.counterAxisSizingMode = 'AUTO';
|
|
25
|
+
tokensRow.itemSpacing = 32;
|
|
26
|
+
tokensRow.paddingLeft = tokensRow.paddingRight = 32;
|
|
27
|
+
tokensRow.paddingTop = tokensRow.paddingBottom = 24;
|
|
28
|
+
tokensRow.fills = [];
|
|
29
|
+
tokensRow.strokes = [];
|
|
30
|
+
const themeNames = getThemeNames(TOKENS);
|
|
31
|
+
for (const themeName of themeNames) {
|
|
32
|
+
tokensRow.appendChild(demoFrameColors(themeName));
|
|
33
|
+
}
|
|
34
|
+
tokensRow.appendChild(demoFrameRadii());
|
|
35
|
+
tokensRow.x = 48;
|
|
36
|
+
tokensRow.y = 48;
|
|
37
|
+
ds.appendChild(tokensRow);
|
|
38
|
+
debug('Tokens row', { columns: tokensRow.children.length, height: tokensRow.height });
|
|
39
|
+
|
|
40
|
+
// Scanner names components after their file (capitalised, dashes kept): gradient-showcase.tsx → 'Gradient-showcase'
|
|
41
|
+
const EFFECTS_COMPONENTS = ['Gradient-showcase'];
|
|
42
|
+
|
|
43
|
+
const offset = tokensRow.y + tokensRow.height + 80;
|
|
44
|
+
const uiSection = createUIComponents(ds, {
|
|
45
|
+
themeNames,
|
|
46
|
+
yOffset: offset,
|
|
47
|
+
xOffset: 48,
|
|
48
|
+
excludeComponents: EFFECTS_COMPONENTS,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const effectsOffset = offset + (uiSection ? uiSection.height : 0) + 80;
|
|
52
|
+
createUIComponents(ds, {
|
|
53
|
+
themeNames,
|
|
54
|
+
yOffset: effectsOffset,
|
|
55
|
+
xOffset: 48,
|
|
56
|
+
sectionTitle: 'Effects',
|
|
57
|
+
onlyComponents: EFFECTS_COMPONENTS,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// --- Dev Server Integration ---
|
|
2
|
+
// Fetching is done via UI iframe (code.js sandbox has no network access).
|
|
3
|
+
// Architecture:
|
|
4
|
+
// 1. code.js shows UI (visible or hidden)
|
|
5
|
+
// 2. UI iframe loads and sends 'ui-ready'
|
|
6
|
+
// 3. code.js sends 'fetch-component-defs' request
|
|
7
|
+
// 4. UI fetches from localhost dev server
|
|
8
|
+
// 5. UI sends 'component-defs-result' back to code.js
|
|
9
|
+
|
|
10
|
+
import { debug } from './colors';
|
|
11
|
+
|
|
12
|
+
interface ComponentDefsResult {
|
|
13
|
+
requestId: string;
|
|
14
|
+
data: ComponentDefs | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ComponentDefs {
|
|
18
|
+
components: unknown[];
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Pending fetch callbacks keyed by requestId
|
|
23
|
+
let _pendingFetchCallbacks: Record<string, (data: ComponentDefs | null) => void> = {};
|
|
24
|
+
let _fetchRequestCounter = 0;
|
|
25
|
+
let _uiReady = false;
|
|
26
|
+
let _uiReadyCallbacks: Array<() => void> = [];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wait for UI iframe to be ready (sends 'ui-ready' on load)
|
|
30
|
+
*/
|
|
31
|
+
export function waitForUIReady(): Promise<void> {
|
|
32
|
+
if (_uiReady) return Promise.resolve();
|
|
33
|
+
return new Promise<void>(function (resolve) {
|
|
34
|
+
_uiReadyCallbacks.push(resolve);
|
|
35
|
+
// Safety timeout - if UI never sends ready, resolve anyway after 500ms
|
|
36
|
+
setTimeout(function () {
|
|
37
|
+
resolve();
|
|
38
|
+
}, 500);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Handle UI ready signal
|
|
44
|
+
*/
|
|
45
|
+
export function handleUIReady(): void {
|
|
46
|
+
_uiReady = true;
|
|
47
|
+
for (let i = 0; i < _uiReadyCallbacks.length; i++) {
|
|
48
|
+
_uiReadyCallbacks[i]();
|
|
49
|
+
}
|
|
50
|
+
_uiReadyCallbacks = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch component definitions from dev server via UI iframe.
|
|
55
|
+
* The UI iframe has network access; code.js sandbox does not.
|
|
56
|
+
* Returns a Promise that resolves with the data or null.
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchComponentDefsFromServer(): Promise<ComponentDefs | null> {
|
|
59
|
+
// Ensure UI is loaded and ready to receive messages
|
|
60
|
+
await waitForUIReady();
|
|
61
|
+
|
|
62
|
+
return new Promise<ComponentDefs | null>(function (resolve) {
|
|
63
|
+
const requestId = 'fetch-' + ++_fetchRequestCounter;
|
|
64
|
+
_pendingFetchCallbacks[requestId] = resolve;
|
|
65
|
+
|
|
66
|
+
debug('Sending fetch request to UI: ' + requestId);
|
|
67
|
+
figma.ui.postMessage({ type: 'fetch-component-defs', requestId: requestId });
|
|
68
|
+
|
|
69
|
+
// Timeout after 10s
|
|
70
|
+
setTimeout(function () {
|
|
71
|
+
if (_pendingFetchCallbacks[requestId]) {
|
|
72
|
+
delete _pendingFetchCallbacks[requestId];
|
|
73
|
+
debug('Fetch timed out');
|
|
74
|
+
resolve(null);
|
|
75
|
+
}
|
|
76
|
+
}, 10000);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Pending image fetch callbacks keyed by requestId
|
|
81
|
+
type ImageFetchResult = { bytes: number[] | null; svgText: string | null };
|
|
82
|
+
const _pendingImageCallbacks: Record<string, (result: ImageFetchResult) => void> = {};
|
|
83
|
+
let _imageRequestCounter = 0;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Handle image fetch result from UI iframe
|
|
87
|
+
*/
|
|
88
|
+
export function handleImageResult(msg: { requestId: string; bytes: number[] | null; svgText?: string | null }): void {
|
|
89
|
+
const cb = _pendingImageCallbacks[msg.requestId];
|
|
90
|
+
if (cb) {
|
|
91
|
+
delete _pendingImageCallbacks[msg.requestId];
|
|
92
|
+
cb({ bytes: msg.bytes, svgText: msg.svgText ?? null });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fetchImageData(url: string): Promise<ImageFetchResult> {
|
|
97
|
+
return new Promise<ImageFetchResult>(function (resolve) {
|
|
98
|
+
const requestId = 'img-' + ++_imageRequestCounter;
|
|
99
|
+
_pendingImageCallbacks[requestId] = resolve;
|
|
100
|
+
figma.ui.postMessage({ type: 'fetch-image', url, requestId });
|
|
101
|
+
// Short timeout — CORS failures reject immediately; slow servers get 3s
|
|
102
|
+
setTimeout(function () {
|
|
103
|
+
if (_pendingImageCallbacks[requestId]) {
|
|
104
|
+
delete _pendingImageCallbacks[requestId];
|
|
105
|
+
resolve({ bytes: null, svgText: null });
|
|
106
|
+
}
|
|
107
|
+
}, 3000);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type PrefetchResult = { hashMap: Map<string, string>; svgMap: Map<string, string> };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pre-fetch a set of image srcs via the UI iframe (which has network access).
|
|
115
|
+
* Relative paths are resolved against localhost on the given port.
|
|
116
|
+
* Fetches all images in parallel. SVGs are stored as raw strings (for createNodeFromSvg);
|
|
117
|
+
* raster images are stored as figma imageHash fills.
|
|
118
|
+
*/
|
|
119
|
+
export async function prefetchImages(
|
|
120
|
+
srcs: string[],
|
|
121
|
+
port: number | string
|
|
122
|
+
): Promise<PrefetchResult> {
|
|
123
|
+
await waitForUIReady();
|
|
124
|
+
const hashMap = new Map<string, string>();
|
|
125
|
+
const svgMap = new Map<string, string>();
|
|
126
|
+
const unique = [...new Set(srcs.filter(Boolean))];
|
|
127
|
+
|
|
128
|
+
const entries = await Promise.all(
|
|
129
|
+
unique.map(async (src) => {
|
|
130
|
+
const url = src.startsWith('http') ? src : `http://localhost:${port}${src}`;
|
|
131
|
+
const data = await fetchImageData(url);
|
|
132
|
+
return { src, ...data };
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
for (const { src, bytes, svgText } of entries) {
|
|
137
|
+
if (svgText) {
|
|
138
|
+
svgMap.set(src, svgText);
|
|
139
|
+
} else if (bytes && bytes.length > 0) {
|
|
140
|
+
try {
|
|
141
|
+
const img = (figma as any).createImage(new Uint8Array(bytes));
|
|
142
|
+
hashMap.set(src, img.hash);
|
|
143
|
+
} catch (_e) { /* ignore */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { hashMap, svgMap };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Handle fetch result from UI iframe
|
|
151
|
+
*/
|
|
152
|
+
export function handleComponentDefsResult(msg: ComponentDefsResult): void {
|
|
153
|
+
const cb = _pendingFetchCallbacks[msg.requestId];
|
|
154
|
+
if (cb) {
|
|
155
|
+
delete _pendingFetchCallbacks[msg.requestId];
|
|
156
|
+
debug('Received component defs from UI, data: ' + (msg.data ? 'yes' : 'null'));
|
|
157
|
+
cb(msg.data);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Update COMPONENT_DEFS with fresh data from server.
|
|
163
|
+
* Returns { success: boolean, data: ComponentDefs | null } so the caller
|
|
164
|
+
* (main.ts) can assign the data to COMPONENT_DEFS.
|
|
165
|
+
*/
|
|
166
|
+
export async function refreshComponentDefs(): Promise<{ success: boolean; data: ComponentDefs | null }> {
|
|
167
|
+
const freshDefs = await fetchComponentDefsFromServer();
|
|
168
|
+
if (freshDefs && freshDefs.components && freshDefs.components.length > 0) {
|
|
169
|
+
debug('Loaded ' + freshDefs.components.length + ' components from dev server');
|
|
170
|
+
return { success: true, data: freshDefs };
|
|
171
|
+
}
|
|
172
|
+
return { success: false, data: null };
|
|
173
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
declare const figma: any;
|
|
2
|
+
|
|
3
|
+
type FontStyleIndex = Record<string, string[]>;
|
|
4
|
+
|
|
5
|
+
let AVAILABLE_STYLES_BY_FAMILY: FontStyleIndex | null = null;
|
|
6
|
+
let INIT_PROMISE: Promise<void> | null = null;
|
|
7
|
+
|
|
8
|
+
function normalizeStyleToken(style: string): string {
|
|
9
|
+
return String(style || '')
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseExplicitNumericWeight(style: string): number | null {
|
|
16
|
+
const normalized = normalizeStyleToken(style);
|
|
17
|
+
const match = normalized.match(/\b([1-9]00)\b/);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
const n = parseInt(match[1], 10);
|
|
20
|
+
if (!Number.isFinite(n)) return null;
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function inferFontWeight(style: string): number {
|
|
25
|
+
const explicit = parseExplicitNumericWeight(style);
|
|
26
|
+
if (explicit != null) return explicit;
|
|
27
|
+
const normalized = normalizeStyleToken(style);
|
|
28
|
+
if (!normalized) return 400;
|
|
29
|
+
if (/\bthin\b/.test(normalized)) return 100;
|
|
30
|
+
if (/\b(extra|ultra)\s*light\b/.test(normalized)) return 200;
|
|
31
|
+
if (/\blight\b/.test(normalized)) return 300;
|
|
32
|
+
if (/\b(book)\b/.test(normalized)) return 350;
|
|
33
|
+
if (/\b(normal|regular|roman)\b/.test(normalized)) return 400;
|
|
34
|
+
if (/\bmedium\b/.test(normalized)) return 500;
|
|
35
|
+
if (/\b(semi|demi)\s*bold\b/.test(normalized)) return 600;
|
|
36
|
+
if (/\bbold\b/.test(normalized) && !/\b(extra|ultra)\s*bold\b/.test(normalized)) return 700;
|
|
37
|
+
if (/\b(extra|ultra|heavy)\s*bold\b/.test(normalized) || /\bheavy\b/.test(normalized)) return 800;
|
|
38
|
+
if (/\bblack\b/.test(normalized)) return 900;
|
|
39
|
+
return 400;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function inferItalic(style: string): boolean {
|
|
43
|
+
const normalized = normalizeStyleToken(style);
|
|
44
|
+
return /\b(italic|oblique)\b/.test(normalized);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function uniquePush(target: string[], value: string): void {
|
|
48
|
+
for (let i = 0; i < target.length; i++) {
|
|
49
|
+
if (target[i] === value) return;
|
|
50
|
+
}
|
|
51
|
+
target.push(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function rankStylesForRequest(availableStyles: string[], requestedStyle: string): string[] {
|
|
55
|
+
const ranked: Array<{ style: string; score: number; styleWeight: number }> = [];
|
|
56
|
+
const requestWeight = inferFontWeight(requestedStyle);
|
|
57
|
+
const requestItalic = inferItalic(requestedStyle);
|
|
58
|
+
const requestToken = normalizeStyleToken(requestedStyle);
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < availableStyles.length; i++) {
|
|
61
|
+
const style = availableStyles[i];
|
|
62
|
+
const styleWeight = inferFontWeight(style);
|
|
63
|
+
const styleItalic = inferItalic(style);
|
|
64
|
+
const styleToken = normalizeStyleToken(style);
|
|
65
|
+
let score = Math.abs(styleWeight - requestWeight);
|
|
66
|
+
if (styleItalic !== requestItalic) score += 120;
|
|
67
|
+
if (styleToken === requestToken) score -= 30;
|
|
68
|
+
if (styleToken.indexOf(requestToken) !== -1 && requestToken) score -= 10;
|
|
69
|
+
ranked.push({ style: style, score: score, styleWeight: styleWeight });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ranked.sort((a, b) => {
|
|
73
|
+
if (a.score !== b.score) return a.score - b.score;
|
|
74
|
+
if (a.styleWeight !== b.styleWeight) {
|
|
75
|
+
// For medium+ requests, prefer heavier fallback on exact distance ties.
|
|
76
|
+
// This avoids picking Regular over SemiBold for font-medium when 500 isn't available.
|
|
77
|
+
if (requestWeight >= 500) return b.styleWeight - a.styleWeight;
|
|
78
|
+
return a.styleWeight - b.styleWeight;
|
|
79
|
+
}
|
|
80
|
+
return a.style.localeCompare(b.style);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const out: string[] = [];
|
|
84
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
85
|
+
uniquePush(out, ranked[i].style);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildFallbackCandidates(requestedStyle: string): string[] {
|
|
91
|
+
const normalized = normalizeStyleToken(requestedStyle);
|
|
92
|
+
if (normalized === 'medium') return ['Medium', 'SemiBold', 'Semibold', 'DemiBold', 'Regular'];
|
|
93
|
+
if (normalized === 'semibold' || normalized === 'semi bold' || normalized === 'demibold' || normalized === 'demi bold') {
|
|
94
|
+
return ['SemiBold', 'Semibold', 'DemiBold', 'Bold', 'Medium', 'Regular'];
|
|
95
|
+
}
|
|
96
|
+
if (normalized === 'bold') return ['Bold', 'SemiBold', 'Semibold', 'Medium', 'Regular'];
|
|
97
|
+
if (normalized === 'regular' || normalized === 'normal') return ['Regular', 'Book', 'Roman', 'Normal'];
|
|
98
|
+
const out: string[] = [];
|
|
99
|
+
if (requestedStyle) out.push(requestedStyle);
|
|
100
|
+
out.push('Regular');
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isWidthAxisVariant(style: string): boolean {
|
|
105
|
+
const normalized = normalizeStyleToken(style);
|
|
106
|
+
return /\b(condensed|narrow|compressed|expanded|extended)\b/.test(normalized);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requestAsksForWidthAxis(requestedStyle: string): boolean {
|
|
110
|
+
return isWidthAxisVariant(requestedStyle);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function initializeFontStyleIndex(): Promise<void> {
|
|
114
|
+
if (AVAILABLE_STYLES_BY_FAMILY != null) return;
|
|
115
|
+
if (INIT_PROMISE) {
|
|
116
|
+
await INIT_PROMISE;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
INIT_PROMISE = (async () => {
|
|
120
|
+
const next: FontStyleIndex = {};
|
|
121
|
+
try {
|
|
122
|
+
const fonts = await figma.listAvailableFontsAsync();
|
|
123
|
+
for (let i = 0; i < fonts.length; i++) {
|
|
124
|
+
const entry = fonts[i];
|
|
125
|
+
const family = entry.fontName.family;
|
|
126
|
+
const style = entry.fontName.style;
|
|
127
|
+
if (!family || !style) continue;
|
|
128
|
+
if (!next[family]) next[family] = [];
|
|
129
|
+
uniquePush(next[family], style);
|
|
130
|
+
}
|
|
131
|
+
AVAILABLE_STYLES_BY_FAMILY = next;
|
|
132
|
+
} catch (_err) {
|
|
133
|
+
AVAILABLE_STYLES_BY_FAMILY = {};
|
|
134
|
+
}
|
|
135
|
+
})();
|
|
136
|
+
await INIT_PROMISE;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getKnownStylesForFamily(family: string): string[] {
|
|
140
|
+
if (AVAILABLE_STYLES_BY_FAMILY == null) return [];
|
|
141
|
+
const styles = AVAILABLE_STYLES_BY_FAMILY[family];
|
|
142
|
+
if (!styles) return [];
|
|
143
|
+
return styles.slice();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getFontStyleCandidatesForFamily(family: string, requestedStyle: string): string[] {
|
|
147
|
+
const out: string[] = [];
|
|
148
|
+
const styles = getKnownStylesForFamily(family);
|
|
149
|
+
if (styles.length > 0) {
|
|
150
|
+
if (requestedStyle) uniquePush(out, requestedStyle);
|
|
151
|
+
const wantsWidthAxis = requestAsksForWidthAxis(requestedStyle);
|
|
152
|
+
const preferredPool = wantsWidthAxis
|
|
153
|
+
? styles
|
|
154
|
+
: styles.filter(style => !isWidthAxisVariant(style));
|
|
155
|
+
const rankedPreferred = rankStylesForRequest(preferredPool.length > 0 ? preferredPool : styles, requestedStyle);
|
|
156
|
+
for (let i = 0; i < rankedPreferred.length; i++) uniquePush(out, rankedPreferred[i]);
|
|
157
|
+
// Keep width-axis variants as late fallback when not explicitly requested.
|
|
158
|
+
if (!wantsWidthAxis) {
|
|
159
|
+
const rankedAll = rankStylesForRequest(styles, requestedStyle);
|
|
160
|
+
for (let i = 0; i < rankedAll.length; i++) uniquePush(out, rankedAll[i]);
|
|
161
|
+
}
|
|
162
|
+
// Keep generic aliases as a final fallback, never before ranked known styles.
|
|
163
|
+
const fallback = buildFallbackCandidates(requestedStyle);
|
|
164
|
+
for (let i = 0; i < fallback.length; i++) uniquePush(out, fallback[i]);
|
|
165
|
+
} else {
|
|
166
|
+
const fallback = buildFallbackCandidates(requestedStyle);
|
|
167
|
+
for (let i = 0; i < fallback.length; i++) uniquePush(out, fallback[i]);
|
|
168
|
+
}
|
|
169
|
+
if (out.length === 0) out.push('Regular');
|
|
170
|
+
return out;
|
|
171
|
+
}
|