Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1#!/usr/bin/env node2// -----------------------------------------------------------------------------3// Lou-Ka — kit Figma · test/run-mock.mjs4// Exécute plugin/code.js hors Figma avec une maquette minimale de l'API5// (frames, textes, styles, variables, pages) pour attraper les erreurs de6// logique JavaScript avant l'import dans Figma. Ne valide pas le rendu.7// Usage : node plugin/test/run-mock.mjs8// -----------------------------------------------------------------------------9import { readFileSync } from "node:fs";10import vm from "node:vm";11import path from "node:path";1213const ROOT = new URL("../..", import.meta.url).pathname;14const code = readFileSync(path.join(ROOT, "plugin/code.js"), "utf8");1516const stats = { nodes: 0, texts: 0, components: 0, sets: 0, styles: 0, variables: 0, images: 0, pages: 0, svg: 0, notes: [] };17const FONTS = { "Space Grotesk": ["Regular", "Medium", "SemiBold", "Bold"], Inter: ["Regular", "Medium", "Semi Bold", "Bold"], "JetBrains Mono": ["Regular", "Medium", "Bold"] };1819class Node {20 constructor(type) {21 this.type = type; this.id = String(++stats.nodes); this.name = type; this.children = []; this.parent = null;22 this.width = 100; this.height = 100; this.x = 0; this.y = 0; this.fills = []; this.strokes = []; this.effects = [];23 this.layoutMode = "NONE"; this.opacity = 1; this.rotation = 0; this.visible = true;24 }25 resize(w, h) { if (!(w > 0) || !(h > 0) || Number.isNaN(w) || Number.isNaN(h)) throw new Error(`resize(${w}, ${h}) invalide sur « ${this.name} »`); this.width = w; this.height = h; }26 rescale(s) { this.width *= s; this.height *= s; }27 appendChild(c) { if (!c || !(c instanceof Node)) throw new Error(`appendChild(${c}) sur « ${this.name} »`); if (c.parent) c.parent.children = c.parent.children.filter((k) => k !== c); c.parent = this; this.children.push(c); this._fit(); }28 remove() { if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this); this.parent = null; }29 findOne(fn) { for (const c of this.children) { if (fn(c)) return c; const r = c.findOne && c.findOne(fn); if (r) return r; } return null; }30 set layoutSizingHorizontal(v) { if (!this.parent || this.parent.layoutMode === "NONE") throw new Error(`layoutSizingHorizontal=${v} hors auto-layout sur « ${this.name} »`); this._lsh = v; }31 get layoutSizingHorizontal() { return this._lsh; }32 set layoutSizingVertical(v) { if (!this.parent || this.parent.layoutMode === "NONE") throw new Error(`layoutSizingVertical hors auto-layout sur « ${this.name} »`); this._lsv = v; }33 get layoutSizingVertical() { return this._lsv; }34 set layoutGrow(v) { if (!this.parent || this.parent.layoutMode === "NONE") throw new Error(`layoutGrow hors auto-layout sur « ${this.name} »`); this._lg = v; }35 get layoutGrow() { return this._lg; }36 set layoutPositioning(v) { if (v === "ABSOLUTE" && (!this.parent || this.parent.layoutMode === "NONE")) throw new Error(`layoutPositioning ABSOLUTE hors auto-layout sur « ${this.name} »`); this._lp = v; }37 get layoutPositioning() { return this._lp || "AUTO"; }38 // approximation grossière de l'auto-layout AUTO (hug) pour des tailles plausibles39 _fit() {40 if (this.layoutMode === "NONE") return;41 const kids = this.children.filter((k) => k.layoutPositioning !== "ABSOLUTE");42 const pt = this.paddingTop || 0, pr = this.paddingRight || 0, pb = this.paddingBottom || 0, pl = this.paddingLeft || 0, gap = this.itemSpacing || 0;43 const H = this.layoutMode === "HORIZONTAL";44 const main = kids.reduce((a, k) => a + (H ? k.width : k.height), 0) + gap * Math.max(0, kids.length - 1);45 const cross = kids.reduce((a, k) => Math.max(a, H ? k.height : k.width), 0);46 const w = H ? main + pl + pr : cross + pl + pr, h = H ? cross + pt + pb : main + pt + pb;47 if (H) { if (this.primaryAxisSizingMode !== "FIXED") this.width = Math.max(1, w); if (this.counterAxisSizingMode !== "FIXED") this.height = Math.max(1, h); }48 else { if (this.primaryAxisSizingMode !== "FIXED") this.height = Math.max(1, h); if (this.counterAxisSizingMode !== "FIXED") this.width = Math.max(1, w); }49 }50}51class Text extends Node {52 constructor() { super("TEXT"); stats.texts++; this._chars = ""; this.fontSize = 12; this._fontName = null; }53 set fontName(f) { if (!f || !FONTS[f.family] || !FONTS[f.family].includes(f.style)) throw new Error(`fontName non chargée : ${JSON.stringify(f)}`); this._fontName = f; this._measure(); }54 get fontName() { return this._fontName; }55 set characters(v) { if (!this._fontName) throw new Error("characters sans fontName"); this._chars = String(v); this._measure(); }56 get characters() { return this._chars; }57 set fontSize(v) { this._fs = v; this._measure(); }58 get fontSize() { return this._fs; }59 set textAutoResize(v) { this._tar = v; }60 get textAutoResize() { return this._tar; }61 _measure() { if (this._tar === "HEIGHT") return; const lines = this._chars.split("\n"); const longest = Math.max(1, ...lines.map((l) => l.length)); this.width = longest * (this._fs || 12) * 0.55; this.height = lines.length * (this._fs || 12) * 1.3; }62 resize(w, h) { super.resize(w, h); const cpl = Math.max(1, Math.floor(w / ((this._fs || 12) * 0.55))); this.height = Math.max(1, Math.ceil(this._chars.length / cpl)) * (this._fs || 12) * 1.5; }63 _check(a, b) { if (a < 0 || b > this._chars.length || a > b) throw new Error(`plage [${a},${b}) hors « ${this._chars} »`); }64 setRangeFills(a, b) { this._check(a, b); } setRangeFontName(a, b, f) { this._check(a, b); if (!FONTS[f.family] || !FONTS[f.family].includes(f.style)) throw new Error("setRangeFontName non chargée"); } setRangeFontSize(a, b) { this._check(a, b); }65}66class Page extends Node { constructor() { super("PAGE"); stats.pages++; } }67const pages = [];68const figma = {69 currentPage: null,70 root: { children: pages },71 createFrame: () => new Node("FRAME"),72 createComponent: () => { stats.components++; return new Node("COMPONENT"); },73 createRectangle: () => new Node("RECTANGLE"),74 createEllipse: () => new Node("ELLIPSE"),75 createText: () => new Text(),76 createPage: () => { const p = new Page(); pages.push(p); return p; },77 createNodeFromSvg: (markup) => { if (typeof markup !== "string" || !markup.includes("<svg")) throw new Error("SVG invalide"); if (/NaN|undefined/.test(markup)) throw new Error("SVG contient NaN/undefined : " + markup.slice(0, 120)); stats.svg++; const n = new Node("FRAME"); const w = +(markup.match(/width="([\d.]+)"/) || [])[1] || 24, h = +(markup.match(/height="([\d.]+)"/) || [])[1] || 24; n.width = w; n.height = h; return n; },78 createImage: (bytes) => { if (!(bytes instanceof Uint8Array) || !bytes.length) throw new Error("createImage sans octets"); stats.images++; return { hash: "img" + stats.images }; },79 base64Decode: (s) => Uint8Array.from(Buffer.from(s, "base64")),80 combineAsVariants: (nodes, parent) => { stats.sets++; const s = new Node("COMPONENT_SET"); for (const n of nodes) { if (n.type !== "COMPONENT") throw new Error("combineAsVariants : nœud non composant"); if (!/=/.test(n.name)) throw new Error(`variante sans « Propriété=Valeur » : ${n.name}`); s.appendChild(n); } parent.appendChild(s); return s; },81 createPaintStyle: () => { stats.styles++; return {}; }, createTextStyle: () => { stats.styles++; return {}; }, createEffectStyle: () => { stats.styles++; return {}; },82 variables: {83 createVariableCollection: (name) => ({ name, modes: [{ modeId: "m1", name: "Mode 1" }], renameMode() {} }),84 createVariable: (name, col, type) => { if (!["COLOR", "FLOAT", "STRING", "BOOLEAN"].includes(type)) throw new Error("type de variable"); stats.variables++; return { name, setValueForMode(mode, v) { if (type === "COLOR" && (v.r == null || v.a == null)) throw new Error(`valeur COLOR invalide pour ${name}`); if (type === "FLOAT" && typeof v !== "number") throw new Error(`FLOAT invalide ${name}`); } }; },85 },86 loadFontAsync: async (f) => { if (!FONTS[f.family] || !FONTS[f.family].includes(f.style)) throw new Error(`police absente ${f.family} ${f.style}`); },87 setCurrentPageAsync: async (p) => { figma.currentPage = p; },88 viewport: { scrollAndZoomIntoView() {} },89 notify: (m) => stats.notes.push(m),90 closePlugin: () => { done(); },91};92figma.currentPage = figma.createPage();9394let done; const finished = new Promise((r) => (done = r));95const sandbox = { figma, console, setTimeout, Promise, Math, JSON, Date, Number, String, Object, Array, Buffer, Uint8Array, Error, parseInt, RegExp };96vm.createContext(sandbox);97const t0 = Date.now();98try {99 vm.runInContext(code, sandbox, { filename: "code.js" });100 await Promise.race([finished, new Promise((_, rej) => setTimeout(() => rej(new Error("timeout 60 s")), 60000))]);101} catch (e) { console.error("✗ ÉCHEC :", e.stack || e); process.exit(1); }102103const err = stats.notes.find((n) => /^Erreur/.test(n));104if (err) { console.error("✗ Le plugin a signalé :", err); process.exit(1); }105const count = (p, pred) => { let n = 0; const walk = (k) => { if (pred(k)) n++; (k.children || []).forEach(walk); }; walk(p); return n; };106console.log(`✓ code.js exécuté sans erreur en ${Date.now() - t0} ms`);107console.log(` pages : ${pages.map((p) => `${p.name} (${count(p, () => true) - 1} nœuds)`).join(" · ")}`);108console.log(` nœuds ${stats.nodes} · textes ${stats.texts} · composants ${stats.components} · jeux de variantes ${stats.sets} · styles ${stats.styles} · variables ${stats.variables} · images ${stats.images} · svg ${stats.svg}`);109console.log(` notifications : ${stats.notes.join(" | ")}`);110