|
(async () => { |
|
// ───────────────────────────────────────────────────────────── |
|
const DRY_RUN = false; // true = report only. Flip to false to apply. |
|
const NS = 'localizer'; // plugin-data namespace used to recognize our own copies |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
await figma.loadAllPagesAsync(); |
|
|
|
const COMPLEX_BV_KEYS = new Set(['fills', 'strokes', 'effects', 'layoutGrids', 'componentProperties', 'textRangeFills']); |
|
|
|
const remoteToLocalStyle = new Map(); // remote style id -> local style id |
|
const localVarMap = new Map(); // original var id -> Variable to bind |
|
const directVarIds = new Set(); |
|
const styleIds = new Set(); |
|
|
|
let styleCreated = 0; |
|
let styleUpdated = 0; |
|
let varCreated = 0; |
|
let varUpdated = 0; |
|
let collCreated = 0; |
|
let collUpdated = 0; |
|
let styleRebinds = 0; |
|
let varRebinds = 0; |
|
|
|
const collectAlias = (a) => { if (a && a.type === 'VARIABLE_ALIAS' && a.id) directVarIds.add(a.id); }; |
|
const getSrc = (o) => { try { return o.getSharedPluginData(NS, 'src') || null; } catch (e) { return null; } }; |
|
const setSrc = (o, k) => { try { o.setSharedPluginData(NS, 'src', k); } catch (e) {} }; |
|
|
|
async function loadNodeFonts(node) { |
|
if (!node.characters || node.characters.length === 0) return; |
|
const seen = new Set(); |
|
for (const s of node.getStyledTextSegments(['fontName'])) { |
|
const k = `${s.fontName.family}__${s.fontName.style}`; |
|
if (!seen.has(k)) { seen.add(k); try { await figma.loadFontAsync(s.fontName); } catch (e) {} } |
|
} |
|
} |
|
|
|
async function traverse(cb) { |
|
for (const page of figma.root.children) { |
|
const stack = [...page.children]; |
|
while (stack.length) { |
|
const n = stack.pop(); |
|
await cb(n); |
|
if (n.type !== 'INSTANCE' && 'children' in n) for (const c of n.children) stack.push(c); |
|
} |
|
} |
|
} |
|
|
|
// ── Index existing local copies we created previously, keyed by source key ── |
|
const localStyles = [ |
|
...await figma.getLocalPaintStylesAsync(), |
|
...await figma.getLocalTextStylesAsync(), |
|
...await figma.getLocalEffectStylesAsync(), |
|
...await figma.getLocalGridStylesAsync(), |
|
]; |
|
const existingStyleBySrc = new Map(); |
|
for (const s of localStyles) { const src = getSrc(s); if (src) existingStyleBySrc.set(src, s); } |
|
|
|
const existingCollBySrc = new Map(); |
|
for (const c of await figma.variables.getLocalVariableCollectionsAsync()) { const src = getSrc(c); if (src) existingCollBySrc.set(src, c); } |
|
const existingVarBySrc = new Map(); |
|
for (const v of await figma.variables.getLocalVariablesAsync()) { const src = getSrc(v); if (src) existingVarBySrc.set(src, v); } |
|
|
|
// ── PHASE A: collect style ids + variable alias ids in use ────────────── |
|
await traverse(async (n) => { |
|
for (const prop of ['fillStyleId', 'strokeStyleId', 'effectStyleId', 'gridStyleId']) |
|
if (prop in n) { const id = n[prop]; if (id && id !== figma.mixed) styleIds.add(id); } |
|
if (n.type === 'TEXT') { |
|
if (n.textStyleId === figma.mixed) { for (const s of n.getStyledTextSegments(['textStyleId'])) if (s.textStyleId) styleIds.add(s.textStyleId); } |
|
else if (n.textStyleId) styleIds.add(n.textStyleId); |
|
if (n.fillStyleId === figma.mixed) { for (const s of n.getStyledTextSegments(['fillStyleId'])) if (s.fillStyleId) styleIds.add(s.fillStyleId); } |
|
} |
|
if (n.boundVariables) for (const k of Object.keys(n.boundVariables)) { |
|
const v = n.boundVariables[k]; |
|
if (Array.isArray(v)) v.forEach(collectAlias); else collectAlias(v); |
|
} |
|
for (const arrProp of ['fills', 'strokes']) if (arrProp in n && Array.isArray(n[arrProp])) for (const p of n[arrProp]) { |
|
if (p && p.boundVariables) for (const f of Object.keys(p.boundVariables)) collectAlias(p.boundVariables[f]); |
|
if (p && p.gradientStops) for (const gs of p.gradientStops) if (gs.boundVariables) for (const f of Object.keys(gs.boundVariables)) collectAlias(gs.boundVariables[f]); |
|
} |
|
if ('effects' in n && Array.isArray(n.effects)) for (const e of n.effects) if (e.boundVariables) for (const f of Object.keys(e.boundVariables)) collectAlias(e.boundVariables[f]); |
|
if ('layoutGrids' in n && Array.isArray(n.layoutGrids)) for (const g of n.layoutGrids) if (g.boundVariables) for (const f of Object.keys(g.boundVariables)) collectAlias(g.boundVariables[f]); |
|
}); |
|
|
|
// variables referenced inside remote styles |
|
for (const id of styleIds) { |
|
const st = await figma.getStyleByIdAsync(id); |
|
if (!st || !st.remote) continue; |
|
const scan = (o) => { if (o && o.boundVariables) for (const f of Object.keys(o.boundVariables)) collectAlias(o.boundVariables[f]); }; |
|
if (st.type === 'PAINT') st.paints.forEach(p => { scan(p); (p.gradientStops || []).forEach(scan); }); |
|
if (st.type === 'EFFECT') st.effects.forEach(scan); |
|
if (st.type === 'GRID') st.layoutGrids.forEach(scan); |
|
} |
|
|
|
// ── Remote-variable dependency closure ────────────────────────────────── |
|
const remoteVarIds = new Set(); |
|
const toProcess = [...directVarIds]; |
|
while (toProcess.length) { |
|
const id = toProcess.pop(); |
|
if (remoteVarIds.has(id) || localVarMap.has(id)) continue; |
|
const v = await figma.variables.getVariableByIdAsync(id); |
|
if (!v) continue; |
|
const coll = await figma.variables.getVariableCollectionByIdAsync(v.variableCollectionId); |
|
if (!coll || !coll.remote) { localVarMap.set(id, v); continue; } |
|
remoteVarIds.add(id); |
|
for (const mId of Object.keys(v.valuesByMode)) { const val = v.valuesByMode[mId]; if (val && val.type === 'VARIABLE_ALIAS') toProcess.push(val.id); } |
|
} |
|
|
|
// ── DRY RUN report (create vs update) ─────────────────────────────────── |
|
if (DRY_RUN) { |
|
let sNew = 0, sUpd = 0, vNew = 0, vUpd = 0; |
|
for (const id of styleIds) { const st = await figma.getStyleByIdAsync(id); if (st && st.remote) (existingStyleBySrc.has(st.key) ? sUpd++ : sNew++); } |
|
for (const id of remoteVarIds) { const v = await figma.variables.getVariableByIdAsync(id); if (v) (existingVarBySrc.has(v.key) ? vUpd++ : vNew++); } |
|
const msg = `[DRY RUN]\nStyles — create ${sNew}, update ${sUpd}\nVariables — create ${vNew}, update ${vUpd}`; |
|
console.log(msg); |
|
figma.notify(msg, { timeout: 7000 }); |
|
return; |
|
} |
|
|
|
// ── PHASE B: create/update collections + variables ────────────────────── |
|
const remoteVars = []; |
|
for (const id of remoteVarIds) remoteVars.push(await figma.variables.getVariableByIdAsync(id)); |
|
const byColl = new Map(); |
|
for (const v of remoteVars) { if (!byColl.has(v.variableCollectionId)) byColl.set(v.variableCollectionId, []); byColl.get(v.variableCollectionId).push(v); } |
|
|
|
const collModeMap = new Map(); |
|
for (const [collId] of byColl) { |
|
const rc = await figma.variables.getVariableCollectionByIdAsync(collId); |
|
let lc = existingCollBySrc.get(rc.key); |
|
const isNew = !lc; |
|
if (isNew) { lc = figma.variables.createVariableCollection(rc.name); setSrc(lc, rc.key); collCreated++; } |
|
else { if (lc.name !== rc.name) lc.name = rc.name; collUpdated++; } |
|
const modeMap = new Map(); |
|
rc.modes.forEach((m, i) => { |
|
const existing = lc.modes.find(x => x.name === m.name); |
|
if (existing) modeMap.set(m.modeId, existing.modeId); |
|
else if (isNew && i === 0) { lc.renameMode(lc.modes[0].modeId, m.name); modeMap.set(m.modeId, lc.modes[0].modeId); } |
|
else modeMap.set(m.modeId, lc.addMode(m.name)); |
|
}); |
|
collModeMap.set(collId, { lc, modeMap }); |
|
} |
|
|
|
// pass 1 — create/find variables |
|
for (const v of remoteVars) { |
|
const { lc } = collModeMap.get(v.variableCollectionId); |
|
let nv = existingVarBySrc.get(v.key); |
|
if (!nv) { nv = figma.variables.createVariable(v.name, lc, v.resolvedType); setSrc(nv, v.key); varCreated++; } |
|
else { if (nv.name !== v.name) nv.name = v.name; varUpdated++; } |
|
try { nv.scopes = v.scopes; } catch (e) {} |
|
nv.description = v.description || ''; |
|
try { nv.hiddenFromPublishing = v.hiddenFromPublishing; } catch (e) {} |
|
try { for (const plat of Object.keys(v.codeSyntax || {})) nv.setVariableCodeSyntax(plat, v.codeSyntax[plat]); } catch (e) {} |
|
localVarMap.set(v.id, nv); |
|
} |
|
// pass 2 — set values per mode, resolving aliases |
|
for (const v of remoteVars) { |
|
const nv = localVarMap.get(v.id); |
|
const { modeMap } = collModeMap.get(v.variableCollectionId); |
|
for (const mId of Object.keys(v.valuesByMode)) { |
|
let val = v.valuesByMode[mId]; |
|
if (val && val.type === 'VARIABLE_ALIAS') { const t = localVarMap.get(val.id); val = t ? { type: 'VARIABLE_ALIAS', id: t.id } : val; } |
|
try { nv.setValueForMode(modeMap.get(mId), val); } catch (e) {} |
|
} |
|
} |
|
|
|
// ── style localization (create or update existing) ────────────────────── |
|
function remapBV(obj, setter) { |
|
let o = obj; |
|
if (obj && obj.boundVariables) for (const f of Object.keys(obj.boundVariables)) { |
|
const al = obj.boundVariables[f]; |
|
if (al && al.type === 'VARIABLE_ALIAS') { const lv = localVarMap.get(al.id); if (lv) o = setter(o, f, lv); } |
|
} |
|
return o; |
|
} |
|
async function ensureLocalStyle(remoteStyleId) { |
|
if (!remoteStyleId) return null; |
|
if (remoteToLocalStyle.has(remoteStyleId)) return remoteToLocalStyle.get(remoteStyleId); |
|
const style = await figma.getStyleByIdAsync(remoteStyleId); |
|
if (!style) { remoteToLocalStyle.set(remoteStyleId, null); return null; } |
|
if (!style.remote) { remoteToLocalStyle.set(remoteStyleId, remoteStyleId); return remoteStyleId; } |
|
|
|
let local = existingStyleBySrc.get(style.key); |
|
const isNew = !local; |
|
if (isNew) { |
|
if (style.type === 'PAINT') local = figma.createPaintStyle(); |
|
else if (style.type === 'EFFECT') local = figma.createEffectStyle(); |
|
else if (style.type === 'GRID') local = figma.createGridStyle(); |
|
else if (style.type === 'TEXT') local = figma.createTextStyle(); |
|
else { remoteToLocalStyle.set(remoteStyleId, null); return null; } |
|
setSrc(local, style.key); |
|
styleCreated++; |
|
} else styleUpdated++; |
|
|
|
if (style.type === 'PAINT') local.paints = style.paints.map(p => remapBV(p, figma.variables.setBoundVariableForPaint)); |
|
if (style.type === 'EFFECT') local.effects = style.effects.map(e => remapBV(e, figma.variables.setBoundVariableForEffect)); |
|
if (style.type === 'GRID') local.layoutGrids = style.layoutGrids.map(g => remapBV(g, figma.variables.setBoundVariableForLayoutGrid)); |
|
if (style.type === 'TEXT') { |
|
await figma.loadFontAsync(style.fontName); |
|
local.fontName = style.fontName; local.fontSize = style.fontSize; |
|
local.letterSpacing = style.letterSpacing; local.lineHeight = style.lineHeight; |
|
local.paragraphIndent = style.paragraphIndent; local.paragraphSpacing = style.paragraphSpacing; |
|
local.textCase = style.textCase; local.textDecoration = style.textDecoration; |
|
} |
|
local.name = style.name; local.description = style.description || ''; |
|
remoteToLocalStyle.set(remoteStyleId, local.id); |
|
return local.id; |
|
} |
|
|
|
// ── PHASE D: rebind every node (styles + variables) ───────────────────── |
|
await traverse(async (n) => { |
|
if (n.type === 'TEXT') await loadNodeFonts(n); |
|
for (const [prop, setter] of [['fillStyleId', 'setFillStyleIdAsync'], ['strokeStyleId', 'setStrokeStyleIdAsync'], ['effectStyleId', 'setEffectStyleIdAsync'], ['gridStyleId', 'setGridStyleIdAsync']]) { |
|
if (prop in n) { const id = n[prop]; if (id && id !== figma.mixed) { const l = await ensureLocalStyle(id); if (l && l !== id) { await n[setter](l); styleRebinds++; } } } |
|
} |
|
if (n.type === 'TEXT') { |
|
if (n.textStyleId === figma.mixed) { for (const s of n.getStyledTextSegments(['textStyleId'])) { if (!s.textStyleId) continue; const l = await ensureLocalStyle(s.textStyleId); if (l && l !== s.textStyleId) { await n.setRangeTextStyleIdAsync(s.start, s.end, l); styleRebinds++; } } } |
|
else if (n.textStyleId) { const l = await ensureLocalStyle(n.textStyleId); if (l && l !== n.textStyleId) { await n.setTextStyleIdAsync(l); styleRebinds++; } } |
|
if (n.fillStyleId === figma.mixed) { for (const s of n.getStyledTextSegments(['fillStyleId'])) { if (!s.fillStyleId) continue; const l = await ensureLocalStyle(s.fillStyleId); if (l && l !== s.fillStyleId) { await n.setRangeFillStyleIdAsync(s.start, s.end, l); styleRebinds++; } } } |
|
} |
|
if (n.boundVariables) for (const field of Object.keys(n.boundVariables)) { |
|
if (COMPLEX_BV_KEYS.has(field)) continue; |
|
const al = n.boundVariables[field]; |
|
if (al && al.type === 'VARIABLE_ALIAS') { const lv = localVarMap.get(al.id); if (lv && lv.id !== al.id) { try { n.setBoundVariable(field, lv); varRebinds++; } catch (e) {} } } |
|
} |
|
const hasStyle = (p) => (p in n) && n[p] && n[p] !== figma.mixed; |
|
const remapArr = (prop, styleProp, setter) => { |
|
if (hasStyle(styleProp)) return; |
|
if (!(prop in n) || !Array.isArray(n[prop])) return; |
|
let changed = false; |
|
const next = n[prop].map(o => remapBV(o, (x, f, lv) => { changed = true; varRebinds++; return setter(x, f, lv); })); |
|
if (changed) n[prop] = next; |
|
}; |
|
remapArr('fills', 'fillStyleId', figma.variables.setBoundVariableForPaint); |
|
remapArr('strokes', 'strokeStyleId', figma.variables.setBoundVariableForPaint); |
|
remapArr('effects', 'effectStyleId', figma.variables.setBoundVariableForEffect); |
|
remapArr('layoutGrids', 'gridStyleId', figma.variables.setBoundVariableForLayoutGrid); |
|
}); |
|
|
|
const msg = `Styles — created ${styleCreated}, updated ${styleUpdated}\n` + |
|
`Variables — created ${varCreated}, updated ${varUpdated} (collections: +${collCreated}/~${collUpdated})\n` + |
|
`Rebinds — styles ${styleRebinds}, variables ${varRebinds}`; |
|
console.log(msg); |
|
figma.notify(`Localized: ${styleCreated + styleUpdated} styles, ${varCreated + varUpdated} variables`, { timeout: 7000 }); |
|
})(); |