Setting the file. One moment.
Deploy Description · Wix Harmony Editor · wix/skills · Skills Docs
ContentsBack to the top of the page — line 182
This file
Number 7.5
Position 5 of 29
Type JavaScript
Size 21 KB
Lines 212 scripts/copy/ deploy-description.mjs
JavaScript · 212 lines · 21 KB
14
// 2… create add components in chunks, each renamed to its description key and given its
15 // content, styles and link (idempotent: a key already in the section is skipped)
16 // n desktop one setComposition with every box and the section height; pin floating items
17 // n+1 mobile one setComposition in the mobile view (positions, widths, height: per view)
18 // n+2 verify read back text, links, styles and both compositions, diff against the
19 // description, save, report publish state before/after
20 // State (key -> componentId) is kept in <desc>.deploy.json, so a failed call is resumed, never
21 // re-run blindly. Requires the editor open on CDP 127.0.0.1:$PW_PORT, default 9333 (SKILL.md → Connect).
22 // Mobile-only Hide and the mobile background-image opacity have no API: run mobile-stage.mjs after.
23 import { chromium } from 'playwright' ;
24 import { hideScrollbars } from './hide-scrollbars.mjs' ;
25 import fs from 'node:fs' ;
26 import { descHash, openItems, pendingItems } from './desc-hash.mjs' ;
27
28 const args = process.argv. slice ( 2 );
29 const opt = ( n , d ) => { const i = args. indexOf ( `--${ n }` ); return i >= 0 ? args[i + 1 ] : d; };
30 const descFile = args. find (( a , i ) => ! a. startsWith ( '--' ) && ! (i > 0 && args[i - 1 ]. startsWith ( '--' )));
31 const pageId = opt ( 'page' );
32 if ( ! descFile || ! pageId) { console. error ( 'usage: deploy-description.mjs <page.desc.json> --page <pageId> [--clean] [--chunk 10]' ); process. exit ( 2 ); }
33 const desc = JSON . parse (fs. readFileSync (descFile, 'utf8' ));
34 if ( ! desc.checkedAt || desc.checkedHash !== descHash (desc)) { console. error ( 'Refused: the description has no passing check for its current content. Run check-description.mjs --write until it passes (it stamps only a clean check).' ); process. exit ( 2 ); }
35 const stateFile = descFile. replace ( / \. json $ / , '' ) + '.deploy.json' ;
36 const state = fs. existsSync (stateFile) ? JSON . parse (fs. readFileSync (stateFile, 'utf8' )) : { pageId, ids: {} };
37 const chunk = Number ( opt ( 'chunk' , 10 ));
38 const saveState = () => fs. writeFileSync (stateFile, JSON . stringify (state, null , 1 ));
39
40 const browser = await chromium. connectOverCDP ( `http://127.0.0.1:${ process . env . PW_PORT || 9333 }` );
41 const page = browser. contexts (). flatMap (( c ) => c. pages ()). find (( p ) => p. url (). includes ( 'editor.wix.com/edit/' ));
42 if ( ! page) { console. error ( 'No Harmony editor page is open.' ); process. exit ( 3 ); }
43 await hideScrollbars (page); // canvas widths must not depend on the OS scrollbar setting
44 const timings = [];
45 async function run ( name , code ) {
46 const t0 = Date. now ();
47 const raw = await page. evaluate ( async ( code ) => { const api = await repluggableAppDebug.host. getAPI ({ name: 'RunEditorLLMCodeToolAPI' , public: true , layer: 'DATA_SERVICE' }); try { return await api. execute ({ code }, { biParams: {} }); } catch (e) { return { thrown: String (e?.message || e) }; } }, code);
48 timings. push ([name, Date. now () - t0]);
49 if (raw.thrown) throw new Error ( `${ name }: ${ raw . thrown }` );
50 const c = JSON . parse (raw.items[ 0 ].content);
51 if ( ! c.success) { const e = new Error ( `${ name }: ${ c . summary }` ); e.detail = c; throw e; }
52 return c.result;
53 }
54 const J = ( v ) => JSON . stringify (v);
55 const plainLine = ( h ) => { const m = h. match ( / ^ <p>( [ ^ <] * )< \/ p> $ / ); return m ? m[ 1 ]. replace ( / / g , ' ' ). replace ( /&/ g , '&' ). replace ( /</ g , '<' ). replace ( />/ g , '>' ) : null ; };
56 const cleanLink = ( l ) => { if ( ! l) return null ; const { sourcePage , unbuilt , ... rest } = l; return rest; };
57 const flow = desc.components. filter (( c ) => c.kind !== 'pinned' );
58
59 // 1 setup -----------------------------------------------------------------------------
60 const bg = desc.section.background;
61 const setup = await run ( 'setup' , `
62 const A = editorLLMApis;
63 const before = (await A.site.getStatus()).isPublished;
64 await A.pages.navigate({ pageId: ${ J ( pageId ) } });
65 await A.breakpoints.toggle({ device: 'desktop' });
66 let secs = await A.sections.list({});
67 let sec = secs.find((s) => s.sectionId === ${ J ( state . sectionId || null ) });
68 if (!sec) sec = await A.sections.add({ name: ${ J ( desc . section . name || desc . page . name || 'Copy' ) } });
69 const removed = [];
70 if (${ args . includes ( '--clean' ) }) {
71 for (const s of secs) if (s.role === 'section' && s.sectionId !== sec.sectionId) { await A.sections.remove({ sectionId: s.sectionId }); removed.push(s.sectionId); }
72 // components this description placed outside the section earlier (pinned ones live on the page)
73 for (const k of ${ J ( desc . components . filter (( c ) => c . kind === 'pinned' ). map (( c ) => c . key )) }) { const f = await A.components.find({ labelContains: k, pageId: ${ J ( pageId ) } }); for (const c of f.components) if (c.label === k) { await A.components.remove({ componentIds: [c.id] }); removed.push(c.id); } }
74 }
75 await A.appearance.setContainerBackground({ componentId: sec.sectionId, properties: { ${ bg . plate ? `image: ${ J ({ uri: bg.plate.uri , name: bg.plate.name , alt: '' , width: bg.plate.width , height: bg.plate.height }) }, fittingType: ${ J ( bg . fittingType ) },` : ''} backgroundColor: ${ J ( bg . backgroundColor ) }${ desc . section . mobile . gradient ? `, backgroundImage: ${ J ( desc . section . mobile . gradient ) }` : ''} } });
76 const kids = (await A.components.getTree({ rootId: sec.sectionId, depth: 1 })).children || [];
77 const pins = [];
78 for (const k of ${ J ( desc . components . filter (( c ) => c . kind === 'pinned' ). map (( c ) => c . key )) }) { const f = await A.components.find({ labelContains: k, pageId: ${ J ( pageId ) } }); for (const c of f.components) if (c.label === k) pins.push([k, c.id]); }
79 return { before, sectionId: sec.sectionId, removed, existing: kids.map((k) => [k.label, k.id]), pins };` );
80 state.sectionId = setup.sectionId; state.publishedBefore = setup.before;
81 // the live page is the truth: drop every remembered id that is no longer there (--clean, a replaced section)
82 { const live = new Map (setup.existing), livePins = new Map (setup.pins); state.ids = {};
83 for ( const c of desc.components) { const id = (c.kind === 'pinned' ? livePins : live). get (c.key); if (id) state.ids[c.key] = id; } }
84 saveState ();
85
86 // 2 create ----------------------------------------------------------------------------
87 // --reapply also re-sends content, styles and links to components that already exist (no add)
88 const reapply = args. includes ( '--reapply' );
89 const todo = desc.components. filter (( c ) => ! state.ids[c.key] || reapply);
90 for ( let i = 0 ; i < todo. length ; i += chunk) {
91 const part = todo. slice (i, i + chunk);
92 const body = part. map (( c ) => {
93 const lines = state.ids[c.key] ? [ `{ const id = ${ J ( state . ids [ c . key ]) }; out[${ J ( c . key ) }] = id;` ] : [ `{ const a = (await A.components.add({ containerId: S, components: [{ componentType: ${ J ( c . type ) } }], select: false })).added[0]; const id = a.componentId; out[${ J ( c . key ) }] = id;` , `await A.components.rename({ componentId: id, label: ${ J ( c . key ) } });` ];
94 if (c.kind === 'text' ) { const pl = plainLine (c.html); lines. push (pl !== null ? `await A.richText.injectText({ componentId: id, text: ${ J ( pl ) } });` : `await A.richText.setText({ componentId: id, text: ${ J ( c . html ) } });` ); }
95 if (c.kind === 'image' ) lines. push ( `await A.content.set({ componentId: id, values: { image: ${ J ({ uri: c.image.uri , name: c.image.name , alt: c.image.alt || '' , width: c.image.width , height: c.image.height }) }, displayMode: ${ J ( c . displayMode ) } } });` );
96 if (c.kind === 'image' && c.displayMode === 'fill' ) lines. push ( `await A.layout.setSize({ componentId: id, width: ${ c . desktop . width }, height: ${ c . desktop . height } });` ); // a cropped image (fill) keeps the box the source drew; composition derives height from the ratio
97 if ([ 'button' , 'hotspot' , 'pinned' ]. includes (c.kind)) lines. push ( `await A.content.writeText({ componentId: id, text: ${ J ( c . label || ' ' ) } });` );
98 for ( const [ el , props ] of Object. entries (c.styles || {})) lines. push ( `await A.appearance.setStyles({ componentId: id, ${ el === 'root' ? '' : `elementPath: ${ J ( el . split ( '/' )) }, `}properties: ${ J ( props ) } });` );
99 if (c.link) lines. push ( `await A.links.set({ componentId: id, link: ${ J ( cleanLink ( c . link )) } });` );
100 else if (reapply && state.ids[c.key] && c.kind !== 'text' ) lines. push ( `await A.links.remove({ componentId: id });` ); // the description has none: an old live link must go (removing a missing one succeeds; any error is real)
101 return lines. join ( ' \n ' ) + ' }' ;
102 }). join ( ' \n ' );
103 const made = await run ( `create ${ i / chunk + 1 }` , `const A = editorLLMApis; const S = ${ J ( state . sectionId ) }; const out = {}; \n ${ body } \n return out;` );
104 Object. assign (state.ids, made); saveState ();
105 }
106
107 // 3 desktop composition + pins ----------------------------------------------------------
108 // text and native (cart icon...) heights follow content; on mobile an image's height is SHARED with desktop (its width is
109 // per view), so mobile sends no image height and lets the ratio derive it
110 const box = ( c , b , dev = 'desktop' ) => (c.kind === 'text' || c.kind === 'native' || (dev === 'mobile' && c.kind === 'image' ) ? { x: b.x, y: b.y, width: b.width } : { x: b.x, y: b.y, width: b.width, height: b.height });
111 const pinned = desc.components. filter (( c ) => c.kind === 'pinned' );
112 // A new text starts narrow and therefore tall; once the first pass gives it its width it
113 // reflows shorter and the boxes below it drift up. So the same set is sent until it lands.
114 const compose = ( dev , list , height , keyOf ) => `
115 const A = editorLLMApis; await A.breakpoints.toggle({ device: '${ dev }' });
116 const want = ${ J ( list . map (( c ) => ({ componentId: state.ids[c.key] , box: box (c, keyOf (c), dev) }))) };
117 let r, pass = 0, off = [];
118 for (; pass < 4; pass++) {
119 // the editor rejects a composition that changes nothing ("resolved back onto the boxes it already had"):
120 // that is fine when the read-back below shows every box already where it was asked to be
121 let noop = false;
122 try { r = await A.layout.setComposition({ componentId: ${ J ( state . sectionId ) }, box: { height: ${ height } }, children: want }); }
123 catch (e) { if (!/back onto the boxes it already had/.test(String((e && e.message) || e))) throw e; noop = true; }
124 const got = await A.layout.getComposition({ componentId: ${ J ( state . sectionId ) } });
125 const at = Object.fromEntries(got.children.map((k) => [k.componentId, k.box]));
126 off = want.filter((w) => !at[w.componentId] || ['x', 'y', 'width', 'height'].some((k) => w.box[k] !== undefined && Math.abs(at[w.componentId][k] - w.box[k]) > 1)).map((w) => [w.componentId, w.box, at[w.componentId] || null]);
127 if (noop) r = got;
128 if (!off.length) break;
129 }` ;
130 const desk = await run ( 'desktop' , compose ( 'desktop' , flow, desc.section.desktop.height, ( c ) => c.desktop) + `
131 const pins = [];
132 ${ pinned . map (( c ) => `{ const id = ${ J ( state . ids [ c . key ]) }; // a pinned component cannot be resized: size it in the flow, then pin
133 if ((await A.layout.getPinToScreen({ componentId: id })).pinned) await A.layout.unpinFromScreen({ componentId: id });
134 await A.layout.setSize({ componentId: id, width: ${ c . desktop . width } });
135 pins.push(await A.layout.pinToScreen({ componentId: id, position: ${ J ( c . pin . position ) }, offsetPx: ${ J ( c . pin . offsetPx ) } })); }` ). join ( ' \n ' ) }
136 return { passes: pass + 1, box: r.box, diagnostics: r.diagnostics, off, pins: pins.map((p) => [p.componentId, p.position, p.offsetPx]) };` );
137
138 // 4 mobile composition ------------------------------------------------------------------
139 const mob = flow. filter (( c ) => ! c.mobile.hidden && c.mobile.final);
140 // Mobile: a first pass sets widths (with the locally predicted y's), then the column is
141 // re-stacked IN THE EDITOR from the heights Harmony actually gave each text (fonts wrap a
142 // little differently there), the same rules as check-description, and sent again. The band
143 // gradient is recomputed from the final y's.
144 const M = desc.section.mobile;
145 const stack = [ ... mob]. sort (( a , b ) => a.mobile.order - b.mobile.order). map (( c ) => ({ // reading order, as check-description stacks it
146 id: state.ids[c.key], fixed: !! c.mobile.box, x: c.mobile.final.x, width: c.mobile.final.width, y: c.mobile.final.y, group: c.mobile.group, row: c.mobile.row || null , continues: !! c.continues, color: c.mobile.color || null , image: c.kind === 'image' }));
147 const mobile = await run ( 'mobile' , compose ( 'mobile' , mob, M .height, ( c ) => c.mobile.final) + `
148 const H = Object.fromEntries((await A.layout.getComposition({ componentId: ${ J ( state . sectionId ) } })).children.map((k) => [k.componentId, k.box]));
149 const L = ${ J ( stack ) }, G = ${ M . gap }, GG = ${ M . groupGap };
150 const near = (a, b) => { const p = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); const x = p(a), y = p(b); return Math.max(...x.map((v, i) => Math.abs(v - y[i]))) < 40; };
151 const fixedEnd = Math.max(0, ...L.filter((c) => c.fixed).map((c) => H[c.id].y + H[c.id].height));
152 let y = 0, last = null, rowKey = null, rowY = 0; const bands = []; const kids = [];
153 for (const c of L) {
154 if (c.fixed) continue;
155 if (y < fixedEnd) y = fixedEnd + GG;
156 if (last && c.group !== last) y += GG;
157 if (c.color && (!bands.length || !near(bands[bands.length - 1].color, c.color))) { if (bands.length) bands[bands.length - 1].end = y - G / 2; bands.push({ color: c.color, start: y - G / 2 }); }
158 let top = y; if (c.row && c.row === rowKey) top = rowY; else { rowKey = c.row; rowY = y; }
159 if (c.continues && !c.row) top = Math.max(top - G, 0);
160 kids.push({ componentId: c.id, box: { x: c.x, y: top, width: c.width } });
161 y = Math.max(y, top + H[c.id].height + G); last = c.group;
162 }
163 const height = Math.round(y + GG);
164 if (bands.length) bands[bands.length - 1].end = height;
165 for (let i = bands.length - 1; i > 0; i--) if (bands[i].end - bands[i].start < 40 || near(bands[i].color, bands[i - 1].color)) { bands[i - 1].end = bands[i].end; bands.splice(i, 1); }
166 let restack = null, restackOff = [];
167 for (let k = 0; k < 3; k++) { try { restack = await A.layout.setComposition({ componentId: ${ J ( state . sectionId ) }, box: { height }, children: kids }); } catch (e) { if (!/back onto the boxes it already had/.test(String((e && e.message) || e))) throw e; restack = await A.layout.getComposition({ componentId: ${ J ( state . sectionId ) } }); } const got = Object.fromEntries((await A.layout.getComposition({ componentId: ${ J ( state . sectionId ) } })).children.map((q) => [q.componentId, q.box])); restackOff = kids.filter((w) => Math.abs(got[w.componentId].y - w.box.y) > 1); if (!restackOff.length) break; }
168 const stops = [[${ J ( M . bands . nav || '#FFFFFF' ) }, 100], [${ J ( M . bands . head || M . bands . nav || '#FFFFFF' ) }, fixedEnd + GG / 2], ...bands.map((b) => [b.color, b.end])];
169 let prev = 0; const gradient = 'linear-gradient(180deg, ' + stops.map(([c, e]) => { const s = c + ' ' + (prev / height * 100).toFixed(2) + '%, ' + c + ' ' + (e / height * 100).toFixed(2) + '%'; prev = e; return s; }).join(', ') + ')';
170 await A.breakpoints.toggle({ device: 'desktop' });
171 await A.appearance.setContainerBackground({ componentId: ${ J ( state . sectionId ) }, properties: { backgroundImage: gradient } }); // shared: desktop's plate covers it
172 return { passes: pass + 1, box: restack.box, diagnostics: restack.diagnostics, off: restackOff, restacked: Object.fromEntries(kids.map((k) => [k.componentId, k.box.y])), height, bands: bands.length };` );
173 // the verify step compares mobile y against the restacked positions
174 for ( const c of mob) if (mobile.restacked[state.ids[c.key]] !== undefined ) c.mobile.final = { ... c.mobile.final, y: mobile.restacked[state.ids[c.key]] };
175
176 // 5 read back, diff INSIDE the editor (a result over ~50 KB comes back truncated), save ----
177 const expect = desc.components. map (( c ) => ({ key: c.key, id: state.ids[c.key], kind: c.kind, text: c.html, link: cleanLink (c.link), styles: c.styles || {}, desktop: c.kind === 'pinned' ? null : c.desktop, mobile: c.mobile?.final || null }));
178 const back = await run ( 'verify' , `
179 const A = editorLLMApis; const S = ${ J ( state . sectionId ) }; const E = ${ J ( expect ) };
180 const num = (v) => parseFloat(String(v));
181 const textOf = (h) => String(h || '').replace(/<[^>]+>/g, '').replace(/ | \u00a0 /g, ' ').replace(/&/g, '&').replace(/ \s +/g, ' ').trim();
182 const comp = async (dev) => { await A.breakpoints.toggle({ device: dev }); const c = await A.layout.getComposition({ componentId: S }); return { box: c.box, kids: Object.fromEntries(c.children.map((k) => [k.componentId, k.box])), overlaps: c.diagnostics.overlaps.length }; };
183 const d = await comp('desktop'); const m = await comp('mobile'); await A.breakpoints.toggle({ device: 'desktop' });
184 const problems = [];
185 for (const e of E) {
186 if (!e.id) { problems.push([e.key, 'not created']); continue; }
187 let vals = {}; try { const v = await A.content.get({ componentId: e.id }); vals = v.values || {}; } catch (err) { problems.push([e.key, 'content unreadable']); }
188 if (e.kind === 'text') { const h = vals.richText ? vals.richText.text : ''; if (textOf(h) !== textOf(e.text)) problems.push([e.key, 'text differs', textOf(h).slice(0, 50)]); if ((h.match(/<span/g) || []).length !== (e.text.match(/<span/g) || []).length) problems.push([e.key, 'inline styling differs']); }
189 if (e.link) { const l = vals.link || {}; if (l.type !== e.link.type || (e.link.pageId && l.pageId !== e.link.pageId) || (e.link.url && l.url !== e.link.url) || (e.link.recipient && l.recipient !== e.link.recipient)) problems.push([e.key, 'link differs', l.type || null]); }
190 if (!e.link && e.kind !== 'text' && vals.link && vals.link.type) problems.push([e.key, 'link should be absent', vals.link.type]);
191 const st = await A.appearance.getStyles({ componentId: e.id });
192 const byEl = Object.fromEntries(st.elements.map((x) => [x.elementPath.join('/') || 'root', x.properties]));
193 for (const [el, props] of Object.entries(e.styles)) for (const [k, v] of Object.entries(props)) {
194 const got = byEl[el] && byEl[el][k] ? byEl[el][k].raw : null;
195 const same = got === v || String(got).toLowerCase() === String(v).toLowerCase() || (/(px|spx|em)$/.test(v) && Math.abs(num(got) - num(v)) <= 0.02 * Math.max(1, num(v))) || (/^(rgb|#)/.test(v) && String(got).replace(/ \s /g, '').toLowerCase() === String(v).replace(/ \s /g, '').toLowerCase());
196 if (!same) problems.push([e.key, el + '.' + k, v, got]);
197 }
198 if (e.desktop) { const b = d.kids[e.id]; if (!b) problems.push([e.key, 'not in desktop layout']); else for (const k of e.kind === 'text' ? ['x', 'y', 'width'] : ['x', 'y', 'width', 'height']) if (Math.abs(b[k] - e.desktop[k]) > 2) problems.push([e.key, 'desktop ' + k, e.desktop[k], b[k]]); }
199 if (e.mobile) { const b = m.kids[e.id]; if (!b) problems.push([e.key, 'not in mobile layout']); else for (const k of ['x', 'y', 'width']) if (Math.abs(b[k] - e.mobile[k]) > 2) problems.push([e.key, 'mobile ' + k, e.mobile[k], b[k]]); }
200 }
201 await A.site.save();
202 return { desktop: { box: d.box, overlaps: d.overlaps }, mobile: { box: m.box, overlaps: m.overlaps }, problems: problems.slice(0, 120), problemCount: problems.length, publishedAfter: (await A.site.getStatus()).isPublished };` );
203 const problems = back.problems;
204 const ok = back.problemCount === 0 && back.desktop.overlaps === 0 && back.mobile.overlaps === 0 && desk.off. length === 0 && mobile.off. length === 0 ;
205 state.publishedAfter = back.publishedAfter;
206 if (ok) { state.deployedAt = new Date (). toISOString (); delete state.lastFailedAt; } else state.lastFailedAt = new Date (). toISOString ();
207 saveState ();
208 const report = { ok, openItems: openItems (desc), pendingItems: pendingItems (desc), sectionId: state.sectionId, components: Object. keys (state.ids). length , editorCalls: timings. length , ms: timings, desktop: { passes: desk.passes, box: back.desktop.box, overlaps: back.desktop.overlaps, stillOff: desk.off. length , pins: desk.pins }, mobile: { passes: mobile.passes, box: back.mobile.box, overlaps: back.mobile.overlaps, stillOff: mobile.off. length }, problemCount: back.problemCount, problems, publishedBefore: state.publishedBefore, publishedAfter: back.publishedAfter };
209 fs. writeFileSync (descFile. replace ( / \. json $ / , '' ) + '.deploy-report.json' , JSON . stringify (report, null , 1 ));
210 console. log ( JSON . stringify (report));
211 await browser. close ();
212 if ( ! ok) { console. error ( `deploy NOT clean: ${ back . problemCount } read-back differences, overlaps desktop ${ back . desktop . overlaps } / mobile ${ back . mobile . overlaps }, still off ${ desk . off . length }/${ mobile . off . length }. See ${ descFile . replace ( / \. json $ / , '' ) }.deploy-report.json; fix and re-run (no --clean), or --reapply.` ); process. exit ( 1 ); }