Setting the file. One moment.
Split Sections · Wix Harmony Editor · wix/skills · Skills Docs
ContentsBack to the top of the page scripts/copy/ split-sections.mjs
JavaScript · 191 lines · 17 KB
14 // "include": ["<label>"], // components that belong here whatever their position (a gallery taller than its source band)
15 // "place": { "<label>": { "x", "y" } }, // move a component (section coordinates)
16 // "height": 600 } ], // override y1 - y0
17 // "drop": [ "<label prefix>" ] } // components removed (static copies of a gallery, spacers)
18 // y0/y1: source page pixels. delta: how far this band's components sit below the source
19 // (Harmony y = source y + delta); components belong to the section holding their centre.
20 // <video>: { "fileId": "5f062c_…" (Media Manager uri of the uploaded mp4), "name", "width", "height" }.
21 //
22 // Calls (one editor run each): inventory (desktop and mobile boxes), then per section: add the
23 // section, background, videos, reparent, desktop composition, mobile composition; then remove
24 // the old section and save. Mobile: components keep their old order and spacing within the
25 // section, restarted at the section top (a video goes first, full width, 16:9).
26 // A video value must carry adaptiveSources (even []): without it the saved document cannot be loaded again
27 // (document services fail on `.map` of undefined and the editor never opens).
28 // Hide-on-mobile and mobile background opacity have no API: run mobile-stage.mjs after.
29 import { chromium } from 'playwright' ;
30 import { hideScrollbars } from './hide-scrollbars.mjs' ;
31 import fs from 'node:fs' ;
32
33 const args = process.argv. slice ( 2 );
34 const opt = ( n , d ) => { const i = args. indexOf ( `--${ n }` ); return i >= 0 ? args[i + 1 ] : d; };
35 const specFile = args. find (( a , i ) => ! a. startsWith ( '--' ) && ! (i > 0 && args[i - 1 ]. startsWith ( '--' )));
36 const pageId = opt ( 'page' ), oldId = opt ( 'section' );
37 if ( ! specFile || ! pageId || ! oldId) { console. error ( 'usage: split-sections.mjs <spec.json> --page <pageId> --section <oldSectionId> [--keep-old]' ); process. exit ( 2 ); }
38 const spec = JSON . parse (fs. readFileSync (specFile, 'utf8' ));
39 const stateFile = specFile. replace ( / \. json $ / , '' ) + '.split-state.json' ;
40 const state = fs. existsSync (stateFile) ? JSON . parse (fs. readFileSync (stateFile, 'utf8' )) : { sections: {} };
41 const save = () => fs. writeFileSync (stateFile, JSON . stringify (state, null , 1 ));
42 const J = JSON .stringify;
43
44 // HZ_EDITOR_RUNNER=<module>: a module whose default export is async (code) => result, used instead of the live
45 // editor. The tests drive the real script against a fake editor this way; nothing else uses it.
46 let browser = null ;
47 let run;
48 if (process.env. HZ_EDITOR_RUNNER ) {
49 const runner = ( await import (process.env. HZ_EDITOR_RUNNER )).default;
50 run = ( name , code ) => runner (code, name);
51 } else {
52 browser = await chromium. connectOverCDP ( `http://127.0.0.1:${ process . env . PW_PORT || 9333 }` );
53 const page = browser. contexts (). flatMap (( c ) => c. pages ()). find (( p ) => p. url (). includes ( 'editor.wix.com/edit/' ));
54 if ( ! page) { console. error ( 'No Harmony editor page is open.' ); process. exit ( 3 ); }
55 await hideScrollbars (page);
56 run = async ( name , code ) => {
57 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);
58 if (raw.thrown) throw new Error ( `${ name }: ${ raw . thrown }` );
59 const c = JSON . parse (raw.items[ 0 ].content);
60 if ( ! c.success) { const e = new Error ( `${ name }: ${ c . summary }` ); e.detail = c; throw e; }
61 return c.result;
62 };
63 }
64 const videoValue = ( v ) => ({ uri: `video/${ v . fileId }/720p/mp4/file.mp4` , name: v.name, hasAudio: false , duration: v.duration || 10 , adaptiveSources: [], sources: [{ quality: '720p' , width: 1280 , height: 720 , types: [{ format: 'mp4' , uri: `video/${ v . fileId }/720p/mp4/file.mp4` }] }, { quality: '480p' , width: 854 , height: 480 , types: [{ format: 'mp4' , uri: `video/${ v . fileId }/480p/mp4/file.mp4` }] }], poster: { uri: `${ v . fileId }f002.jpg` , name: `${ v . name }.jpg` , width: 1280 , height: 720 } });
65
66 // 1 inventory ---------------------------------------------------------------------------
67 if ( ! state.inventory) {
68 state.inventory = await run ( 'inventory' , `const A = editorLLMApis;
69 await A.pages.navigate({ pageId: ${ J ( pageId ) } }); await A.breakpoints.toggle({ device: 'desktop' });
70 const d = await A.layout.getComposition({ componentId: ${ J ( oldId ) } });
71 await A.breakpoints.toggle({ device: 'mobile' }); const m = await A.layout.getComposition({ componentId: ${ J ( oldId ) } }); await A.breakpoints.toggle({ device: 'desktop' });
72 const mb = Object.fromEntries(m.children.map((k) => [k.componentId, k.box]));
73 return { desktopBox: d.box, mobileBox: m.box, children: d.children.map((k) => ({ id: k.componentId, label: k.label, type: k.type, box: k.box, mobile: mb[k.componentId] || null })) };` );
74 save ();
75 }
76 const isDropped = ( label ) => (spec.drop || []). some (( p ) => label. startsWith (p));
77 const all = state.inventory.children;
78 const dropIds = all. filter (( c ) => isDropped (c.label)). map (( c ) => c.id);
79 const live = all. filter (( c ) => ! isDropped (c.label));
80 const band = ( s ) => [s.y0 + s.delta, s.y1 + s.delta];
81 const sectionOf = ( c ) => { const forced = spec.sections. findIndex (( s ) => (s.include || []). includes (c.label)); if (forced >= 0 ) return forced; const mid = c.box.y + c.box.height / 2 ; return spec.sections. findIndex (( s ) => { const [ a , b ] = band (s); return mid >= a && mid < b; }); };
82 const assigned = spec.sections. map (() => []);
83 const lost = [];
84 for ( const c of live) { const i = sectionOf (c); if (i < 0 ) lost. push (c.label); else assigned[i]. push (c); }
85 if (lost. length ) { console. error ( 'components outside every section:' , lost. join ( ', ' )); process. exit ( 4 ); }
86
87 // 2 per section ---------------------------------------------------------------------------
88 const kindOf = ( t ) => ( /RichText/ . test (t) ? 'text' : /Image $| \. Image/ . test (t) ? 'image' : /Slider | Gallery | Cart/ . test (t) ? 'native' : /Video/ . test (t) ? 'video' : 'other' );
89 const sized = ( c , b , dev ) => { const k = kindOf (c.type); return k === 'text' || k === 'native' || (dev === 'mobile' && k === 'image' ) ? { x: b.x, y: b.y, width: b.width } : { x: b.x, y: b.y, width: b.width, height: b.height }; };
90 let prev = oldId;
91 let mutated = false , saved = false ;
92 for ( let i = 0 ; i < spec.sections. length ; i ++ ) {
93 const s = spec.sections[i];
94 const done = state.sections[s.name];
95 if (done && done.finished && opt ( 'redo-mobile' , null ) !== s.name) { prev = done.id; continue ; } // --redo-mobile "<section name>" re-sends one section's layout
96 const [ a ] = band (s);
97 const height = s.height || (s.y1 - s.y0);
98 const place = s.place || {};
99 const items = assigned[i]. map (( c ) => ({ c, box: { ... c.box, y: place[c.label]?.y ?? Math. round (c.box.y - a), x: place[c.label]?.x ?? c.box.x } }));
100 // mobile layout: old order and spacing, restarted at the section top; a video goes first
101 const vids = s.videos || [];
102 const vidH = Math. round ( 390 * 9 / 16 ), top = vids. length ? vids. length * (vidH + 12 ) : 0 ;
103 const mob = items. filter (( it ) => it.c.mobile). sort (( x , y ) => x.c.mobile.y - y.c.mobile.y);
104 let mobBoxes, mobHeight;
105 if (s.cards) {
106 // cards: an image with the texts under it in its column stay together (a grid of tiles stacks tile by tile,
107 // right to left for RTL), instead of all images first and all captions after
108 const imgs = mob. filter (( it ) => kindOf (it.c.type) === 'image' && it.box.width >= 100 );
109 const cardOf = new Map (); const rest = [];
110 for ( const it of mob) { if (imgs. includes (it)) { cardOf. set (it, { img: it, below: [] }); continue ; }
111 const host = imgs. filter (( im ) => it.box.y >= im.box.y + im.box.height - 5 && Math. min (it.box.x + it.box.width, im.box.x + im.box.width) - Math. max (it.box.x, im.box.x) >= 0.5 * Math. min (it.box.width, im.box.width)). sort (( m , n ) => n.box.y - m.box.y)[ 0 ];
112 if (host) cardOf. get (host).below. push (it); else rest. push (it); }
113 const units = [ ... cardOf. values ()]. map (( cd ) => ({ key: [Math. round (cd.img.box.y / 20 ), - cd.img.box.x], list: [cd.img, ... cd.below. sort (( m , n ) => m.box.y - n.box.y)] }))
114 . concat (rest. map (( it ) => ({ key: [Math. round (it.box.y / 20 ), - it.box.x], list: [it] })));
115 units. sort (( m , n ) => m.key[ 0 ] - n.key[ 0 ] || m.key[ 1 ] - n.key[ 1 ]);
116 let y = 16 + top; mobBoxes = [];
117 for ( const u of units) { for ( const it of u.list) { mobBoxes. push ({ id: it.c.id, label: it.c.label, type: it.c.type, box: { x: it.c.mobile.x, y: Math. round (y), width: it.c.mobile.width, height: it.c.mobile.height } }); y += it.c.mobile.height + 6 ; } y += 14 ; }
118 mobHeight = Math. round (y + 10 );
119 } else {
120 const minY = mob. length ? Math. min ( ... mob. map (( it ) => it.c.mobile.y)) : 0 ;
121 mobBoxes = mob. map (( it ) => ({ id: it.c.id, label: it.c.label, type: it.c.type, box: { x: it.c.mobile.x, y: Math. round (it.c.mobile.y - minY + 16 + top), width: it.c.mobile.width, height: it.c.mobile.height } }));
122 mobHeight = Math. max (top, ... mobBoxes. map (( m ) => m.box.y + m.box.height + 16 ), 120 );
123 }
124 const code = `const A = editorLLMApis;
125 await A.pages.navigate({ pageId: ${ J ( pageId ) } }); await A.breakpoints.toggle({ device: 'desktop' });
126 const existing = ${ J ( done ? done . id : null ) };
127 let sec; if (existing) sec = { sectionId: existing }; else sec = await A.sections.add({ name: ${ J ( s . name ) }, afterSectionId: ${ J ( prev ) } });
128 const SID = sec.sectionId;
129 try { await A.view.scrollTo({ componentId: SID }); } catch (e) {} // an off-screen section is not rendered, so it has no size to compose
130 await new Promise((r) => setTimeout(r, 800));
131 const bg = { backgroundColor: ${ J ( s . background . color || '#FFFFFF' ) }${ s . background . plate ? `, image: ${ J ({ uri: s.background.plate.uri , name: s.background.plate.name , alt: '' , width: s.background.plate.width , height: s.background.plate.height }) }, fittingType: 'fill'` : ''}${ s . background . video ? `, videoMedia: ${ J ( videoValue ( s . background . video )) }, loop: true, autoplay: true, showControls: false, muted: true` : ''} };
132 await A.appearance.setContainerBackground({ componentId: SID, properties: bg });
133 const newVids = [];
134 ${ vids . map (( v , k ) => `{ const have = existing ? ((await A.components.getTree({ rootId: SID, depth: 1 })).children || []).find((q) => q.label === ${ J ( `video-${ s . name }-${ k }` ) }) : null; // a re-run reuses the video it already made
135 if (have) newVids.push(have.id); else { const r = await A.components.add({ containerId: SID, components: [{ componentType: 'wixEditorElements.VideoUpload' }], select: false }); const id = r.added[0].componentId; await A.components.rename({ componentId: id, label: ${ J ( `video-${ s . name }-${ k }` ) } });
136 await A.content.set({ componentId: id, values: { video: ${ J ( videoValue ( v . video )) }, autoplay: true, loop: true, mute: true, enableControls: false } }); newVids.push(id); } }` ). join ( ' \n ' ) }
137 const ids = ${ J ( items . map (( it ) => it . c . id )) };
138 if (ids.length) await A.components.reparent({ componentIds: ids, targetContainerId: SID });
139 const kids = [${ items . map (( it ) => `{ componentId: ${ J ( it . c . id ) }, box: ${ J ( sized ( it . c , it . box , 'desktop' )) } }` ). join ( ', ' ) }${ vids . length && items . length ? ', ' : ''}${ vids . map (( v , k ) => `{ componentId: newVids[${ k }], box: ${ J ( v . box ) } }` ). join ( ', ' ) }];
140 const comp = async (dev, h, list) => { await A.breakpoints.toggle({ device: dev }); let r, off = [];
141 for (let pass = 0; pass < 4; pass++) { let noop = false; try { r = await A.layout.setComposition({ componentId: SID, box: { height: h }, children: list }); } catch (e) { if (!/back onto the boxes it already had/.test(String((e && e.message) || e))) throw e; noop = true; }
142 const got = Object.fromEntries((await A.layout.getComposition({ componentId: SID })).children.map((q) => [q.componentId, q.box]));
143 off = list.filter((w) => !got[w.componentId] || ['x', 'y', 'width', 'height'].some((k) => w.box[k] !== undefined && Math.abs(got[w.componentId][k] - w.box[k]) > 1)).map((w) => [w.componentId, w.box, got[w.componentId] || null]); if (!off.length) break; }
144 return { off, box: r && r.box }; };
145 const out = { id: SID };
146 out.desktop = kids.length ? await comp('desktop', ${ height }, kids) : (await A.layout.setComposition({ componentId: SID, box: { height: ${ height } }, children: [] }).catch((e) => String(e.message).slice(0, 200)));
147 const mkids = [${ mobBoxes . map (( m ) => `{ componentId: ${ J ( m . id ) }, box: ${ J ( sized ({ type: m.type }, m . box , 'mobile' )) } }` ). join ( ', ' ) }${ vids . length && mobBoxes . length ? ', ' : ''}${ vids . map (( v , k ) => `{ componentId: newVids[${ k }], box: { x: 0, y: ${ 16 + k * ( vidH + 12 ) }, width: 390, height: ${ vidH } } }` ). join ( ', ' ) }];
148 out.mobile = mkids.length ? await comp('mobile', ${ mobHeight }, mkids) : null;
149 // a mobile write can move desktop boxes: read the desktop view back and count what is off now
150 await A.breakpoints.toggle({ device: 'desktop' });
151 if (kids.length) { const gd = Object.fromEntries((await A.layout.getComposition({ componentId: SID })).children.map((q) => [q.componentId, q.box])); const offD = kids.filter((w) => !gd[w.componentId] || ['x', 'y', 'width', 'height'].some((k) => w.box[k] !== undefined && Math.abs(gd[w.componentId][k] - w.box[k]) > 1)); out.desktop.off = offD.map((w) => [w.componentId, w.box, gd[w.componentId] || null]); }
152 out.newVids = newVids;
153 return out;` ;
154 const res = await run ( `section ${ s . name }` , code);
155 mutated = true ; // layout was written: it must be saved even when the one-time cleanup is already done or skipped (--keep-old)
156 const dOff = res.desktop && res.desktop.off ? res.desktop.off. length : 0 , mOff = res.mobile && res.mobile.off ? res.mobile.off. length : 0 ;
157 // a section with boxes that never landed is NOT finished: keep it resumable and keep the original section
158 state.sections[s.name] = { id: res.id, newVids: res.newVids, finished: dOff === 0 && mOff === 0 , desktopOff: dOff, mobileOff: mOff };
159 save (); prev = res.id;
160 console. log (s.name, JSON . stringify ({ id: res.id, items: items. length , desktopOff: dOff, mobileOff: mOff }));
161 if (dOff || mOff) { console. error ( `section "${ s . name }" has boxes that did not land (desktop ${ dOff }, mobile ${ mOff }): the original section is kept; fix the spec and re-run (finished sections are skipped)` ); if (browser) await browser. close (); process. exit ( 1 ); }
162 }
163
164 // 3 drop, remove the old section, save -------------------------------------------------------
165 // Idempotent: the old section is looked up first, and what was done is persisted, so a repeat (--redo-mobile after a
166 // finished split, a re-run after a failed save) never queries or removes a section that is already gone.
167 if ( ! args. includes ( '--keep-old' ) && spec.sections. every (( s ) => state.sections[s.name] && state.sections[s.name].finished)) {
168 if (state.cleanup && state.cleanup.done) console. log ( JSON . stringify ({ cleanup: 'already done' }));
169 else {
170 const fin = await run ( 'finish' , `const A = editorLLMApis;
171 const OLD = ${ J ( oldId ) };
172 const exists = (await A.sections.list({})).some((s) => s.sectionId === OLD);
173 let dropped = 0, left = [], removed = !exists;
174 if (exists) {
175 const DROP = ${ J ( dropIds ) };
176 const kids = (await A.components.getTree({ rootId: OLD, depth: 1 })).children || [];
177 const present = kids.filter((k) => DROP.includes(k.id)).map((k) => k.id); // only the ones still there: a repeat removes nothing twice
178 if (present.length) { await A.components.remove({ componentIds: present }); dropped = present.length; }
179 left = kids.filter((k) => !present.includes(k.id));
180 if (!left.length) { await A.sections.remove({ sectionId: OLD }); removed = true; }
181 }
182 await A.site.save();
183 return { dropped, left: left.map((k) => k.label), removed };` );
184 saved = true ; // the cleanup call ends with site.save()
185 state.cleanup = { done: fin.removed, ... fin }; save ();
186 console. log ( JSON . stringify (fin));
187 }
188 }
189 // separate from the one-time cleanup: a mobile redo, a repeat after a failed save, or --keep-old still has unsaved layout writes
190 if (mutated && ! saved) { await run ( 'save' , 'return await editorLLMApis.site.save();' ); console. log ( JSON . stringify ({ saved: true })); }
191 if (browser) await browser. close ();