Files
haptika/mobile/scripts/import-reference.cjs
2026-09-08 09:07:27 +02:00

41 lines
8.3 KiB
JavaScript

// Convert the saved Figma reference into React Native scene data. No screenshot
// is used as a screen: text, fills and controls remain individual native layers.
const fs=require('fs'),path=require('path'),parser=require('@babel/parser');
const root=path.resolve(__dirname,'..');
const contexts=JSON.parse(fs.readFileSync(root+'/design/contexts.json'));
const native=JSON.parse(fs.readFileSync(root+'/design/native-properties.json'));
const api=new Map(native.flatMap(s=>s.layers.map(l=>[l.id,l])));
const xml=fs.readFileSync(root+'/design/metadata.xml','utf8');
const decode=s=>s.replace(/&quot;/g,'"').replace(/&apos;|&#39;/g,"'").replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/g,'&');
const nodes={},stack=[];
for(const line of xml.split('\n')){if(/^\s*<\//.test(line)){stack.pop();continue;}const m=line.match(/^\s*<([\w-]+)\s+(.*?)(\/?)>/);if(!m)continue;const a={};for(const [,k,v]of m[2].matchAll(/([\w-]+)="([^"]*)"/g))a[k]=decode(v);const parent=stack.at(-1);const n={id:a.id,name:a.name,t:m[1].toUpperCase(),x:+a.x||0,y:+a.y||0,w:+a.width||0,h:+a.height||0,parent,section:m[1]==='section'?a.name:parent?.section};n.gx=n.x+(parent?.gx||0);n.gy=n.y+(parent?.gy||0);nodes[n.id]=n;if(!m[3])stack.push(n);}
function attr(n,key){const a=n.openingElement.attributes.find(a=>a.name?.name===key);return a?.value?.type==='StringLiteral'?a.value.value:a?.value?.expression;}
function visit(n,fn){if(!n||typeof n!=='object')return;fn(n);for(const[k,v]of Object.entries(n)){if(['loc','extra'].includes(k))continue;if(Array.isArray(v))v.forEach(a=>visit(a,fn));else if(v&&typeof v==='object')visit(v,fn);}}
function value(n){if(!n)return '';if(n.type==='StringLiteral'||n.type==='NumericLiteral')return String(n.value);if(n.type==='TemplateLiteral')return n.quasis.map(q=>q.value.cooked).join('');return '';}
function textContent(n){if(n.type==='JSXText'){if(!n.value.trim())return '';return n.value.replace(/\s+/g,' ');}if(n.type==='JSXExpressionContainer')return value(n.expression);if(n.type==='JSXElement'){const tag=n.openingElement.name.name;return (n.children||[]).map(textContent).join('')+(tag==='p'?'\n':tag==='br'?'\n':'');}return '';}
function color(s){if(!s)return null;s=s.replace(/_/g,' ');if(s==='white')s='#ffffff';if(s==='black')s='#000000';if(s==='transparent')return {r:0,g:0,b:0,a:0};if(s.startsWith('#')){let h=s.slice(1);if(h.length===3)h=h.split('').map(c=>c+c).join('');return {r:parseInt(h.slice(0,2),16)/255,g:parseInt(h.slice(2,4),16)/255,b:parseInt(h.slice(4,6),16)/255,a:h.length===8?parseInt(h.slice(6),16)/255:1};}const v=s.match(/rgba?\(([^)]+)\)/);if(v){const a=v[1].split(/[,\s]+/).map(Number);return {r:a[0]/255,g:a[1]/255,b:a[2]/255,a:a[3]??1};}return null;}
const bracket=(c,key)=>c.match(new RegExp('(?:^| )'+key+'-\\[([^\\]]+)\\]'))?.[1];
const px=(c,key,fallback)=>{const matches=[...c.matchAll(new RegExp('(?:^| )'+key+'-\\[([^\\]]+)\\]','g'))];const v=matches.map(m=>m[1]).find(v=>/^-?[\d.]+px$/.test(v));return v?parseFloat(v):fallback;};
function paint(c,key){const s=bracket(c,key)||c.match(new RegExp('(?:^| )'+key+'-(white|black|transparent)(?: |$)'))?.[1];const col=color(s);return col?[{type:'SOLID',color:col,opacity:1}]:[];}
function fillsFromStyle(n){const s=attr(n,'style');if(s?.type!=='ObjectExpression')return [];const bg=s.properties.find(p=>p.key?.name==='backgroundImage')?.value;const str=value(bg);if(!str)return [];const colors=[...str.matchAll(/rgba?\([^)]+\)/g)].map(m=>color(m[0])).filter(Boolean);if(!colors.length)return [];if(colors.every(c=>JSON.stringify(c)===JSON.stringify(colors[0])))return [{type:'SOLID',color:colors[0],opacity:1}];if(str.startsWith('linear-gradient(90deg')&&colors.length===4&&JSON.stringify(colors[0])===JSON.stringify(colors[1]))return [{type:'SOLID',color:colors[0],opacity:1}];return [{type:'GRADIENT_LINEAR',gradientStops:colors.map((color,i)=>({position:i/(colors.length-1),color}))}];}
const manifest={},out=[];const errors=[];
for(const [id,code]of Object.entries(contexts)){
const frame=nodes[id];if(!frame){errors.push(id);continue;}const ast=parser.parse(code,{sourceType:'module',plugins:['jsx','typescript']});const urls={};let jsx;
visit(ast,n=>{if(n.type==='VariableDeclarator'&&n.init?.type==='StringLiteral')urls[n.id.name]=n.init.value;if(n.type==='JSXElement'&&attr(n,'data-node-id')===id)jsx=n;});if(!jsx){errors.push(id);continue;}
const imageCounts={};const scene={id,name:frame.name,w:frame.w,h:frame.h,section:frame.section,canvasX:frame.x,canvasY:frame.y,layers:[]};
function walk(n,nearest=null,ancestors=[],clips=[],opacity=1){if(n.type!=='JSXElement')return;const nid=attr(n,'data-node-id');const cls=attr(n,'className')||'';const meta=nodes[nid];let current=nearest,nextAnc=ancestors,nextClips=clips,nextOpacity=opacity;
if(meta){current={id:nid,name:meta.name,t:meta.t,x:meta.gx-frame.gx,y:meta.gy-frame.gy,w:meta.w,h:meta.h,path:ancestors};const original=api.get(nid);const opacityClass=cls.match(/(?:^| )opacity-(\d+)(?: |$)/)?.[1];nextOpacity=opacity*(original?.o??(opacityClass?Number(opacityClass)/100:1));current.o=nextOpacity;const ownDataChildren=[];for(const c of n.children)visit(c,v=>{if(v.type==='JSXElement'&&attr(v,'data-node-id'))ownDataChildren.push(v);});const rawText=textContent(n).replace(/\n$/,'');const isText=meta.t==='TEXT'||(!ownDataChildren.length&&rawText.trim()&&!/<img/.test(''));
if(isText){current.nowrap=cls.includes('whitespace-nowrap');current.text=original?.text??rawText;current.t='TEXT';current.align=original?.align??(cls.includes('text-center')?'CENTER':cls.includes('text-right')?'RIGHT':'LEFT');current.valign=original?.valign??(cls.includes('justify-center')?'CENTER':'TOP');
const size=px(cls,'text',16);const font=[...cls.matchAll(/font-\[([\d.]+)\]/g)].map(m=>m[1])[0]||bracket(cls,'font');const weight=font&&/^\d/.test(font)?+font:cls.includes('font-bold')?700:cls.includes('font-semibold')?600:cls.includes('font-medium')?500:cls.includes('font-light')?300:400;
let line;visit(n,v=>{if(v.type==='JSXElement'){const c=attr(v,'className');if(typeof c==='string'&&px(c,'leading',0))line=px(c,'leading');}});
current.runs=original?.runs??[{text:current.text,font:cls.includes('Inter')?'Inter':'SF Pro',size,weight,fill:paint(cls,'text'),line:{unit:'PIXELS',value:line||size*1.2},spacing:{unit:'PIXELS',value:px(cls,'tracking',0)},case:cls.includes('uppercase')?'UPPER':cls.includes('lowercase')?'LOWER':'ORIGINAL',decoration:cls.includes('underline')?'UNDERLINE':'NONE'}];}
else{current.f=original?.f??[...paint(cls,'bg'),...fillsFromStyle(n)];current.r=original?.r??px(cls,'rounded',cls.includes('rounded-full')?9999:cls.includes('rounded-')?8:0);current.s=original?.s??paint(cls,'border');current.sw=original?.sw??(current.s.length?1:0);if(original?.e)current.e=original.e;}
if(clips.length)current.clips=clips;
scene.layers.push(current);nextAnc=[...ancestors,nid];if(/overflow-(clip|hidden)/.test(cls))nextClips=[...clips,{x:current.x,y:current.y,w:current.w,h:current.h,r:current.r||0}];}
if(n.openingElement.name.name==='img'&&current){const src=attr(n,'src');const url=typeof src==='string'?src:urls[src?.name];if(url){const count=imageCounts[current.id]=(imageCounts[current.id]||0)+1;const imageId=current.id+'-image'+(count>1?'-'+count:'');manifest[imageId]={url,format:url.endsWith('.svg')?'svg':'png'};let box={x:current.x,y:current.y,w:current.w,h:current.h};const dimension=(key,total,base)=>{const v=bracket(cls,key);return v?.endsWith('%')?parseFloat(v)*total/100:v?.endsWith('px')?parseFloat(v):base;};box.x+=dimension('left',box.w,0);box.y+=dimension('top',box.h,0);box.w=dimension('w',current.w,box.w);box.h=dimension('h',current.h,box.h);scene.layers.push({id:imageId,name:current.name,t:'IMAGE',...box,path:nextAnc,o:nextOpacity,asset:imageId,clips:url.endsWith('.png')?[...nextClips,{x:current.x,y:current.y,w:current.w,h:current.h,r:current.r||0}]:nextClips});}}
for(const c of n.children||[])if(c.type==='JSXElement')walk(c,current,nextAnc,nextClips,nextOpacity);
}
walk(jsx);for(const layer of scene.layers)if(['VECTOR','BOOLEAN_OPERATION','STAR','POLYGON'].includes(layer.t)&&scene.layers.some(c=>c.asset&&c.path.includes(layer.id))){layer.f=[];layer.s=[];layer.e=[];}out.push(scene);
}
fs.writeFileSync(root+'/design/scenes.json',JSON.stringify(out));fs.writeFileSync(root+'/design/asset-manifest.json',JSON.stringify(manifest,null,2));console.log({frames:out.length,layers:out.reduce((n,s)=>n+s.layers.length,0),assets:Object.keys(manifest).length,errors});