Setting the file. One moment.
Build Description · Wix Harmony Editor · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 7.1
Position 1 of 29
Type JavaScript
Size 23 KB
Lines 249 scripts/copy/ build-description.mjs
JavaScript · 249 lines · 23 KB
13
// [--name "<Page name>"] [--decorative <img-src-substring>,…] (kept in the plate, no component)
14 //
15 // Deterministic: the same inputs give the same file. Anything it cannot decide is listed in
16 // `review` for the agent (or a person) instead of guessed.
17 import fs from 'node:fs' ;
18 import { openItems, pendingItems } from './desc-hash.mjs' ;
19 import { normU, stripTracking } from './link-url.mjs' ;
20
21 const args = process.argv. slice ( 2 );
22 const opt = ( n , d ) => { const i = args. indexOf ( `--${ n }` ); return i >= 0 ? args[i + 1 ] : d; };
23 const optAll = ( n ) => args. flatMap (( a , i ) => (a === `--${ n }` ? [args[i + 1 ]] : []));
24 const [ srcFile , outFile ] = args. filter (( a , i ) => ! a. startsWith ( '--' ) && ! (i > 0 && args[i - 1 ]. startsWith ( '--' )));
25 if ( ! srcFile || ! outFile || ! opt ( 'fonts' )) { console. error ( 'usage: build-description.mjs <source.json> <out.desc.json> --fonts font-ledger.json [--recipes export.json ...]' ); process. exit ( 2 ); }
26 const src = JSON . parse (fs. readFileSync (srcFile, 'utf8' ));
27 const fontLedger = JSON . parse (fs. readFileSync ( opt ( 'fonts' ), 'utf8' )).families || {};
28 const recipes = optAll ( 'recipes' ). flatMap (( f ) => { const d = JSON . parse (fs. readFileSync (f, 'utf8' )); return d.components || [d]; });
29 const sourceOrigin = opt ( 'source-origin' , null ), localOrigin = opt ( 'local-origin' , null );
30 const pageMap = JSON . parse ( opt ( 'pages' , '{}' ));
31 // --linkmap links.json: { "<source URL>": { link: <Harmony link>, inline?: "<absolute URL for links inside text>" } }.
32 // Every link the source has is looked up here first. Rich text cannot hold a PageLink, so a link inside text uses
33 // `inline` (an absolute URL on the new site). `unbuilt: "<why>"` marks a page the new site does not have yet: its link points at an
34 // existing page for now, and the page stays in desc.pendingItems (the cutover hand-off) until the entry drops `unbuilt`.
35 // Lookup keys are normalised by link-url.mjs (case of the path and query values is kept, tracking parameters are removed).
36 // Build it from the migrated data: a store category or product link
37 // MUST land on the new store's category / product page, never on the source site (see COPY-SITE.md "Links").
38 const rawLinkmap = opt ( 'linkmap' ) ? JSON . parse (fs. readFileSync ( opt ( 'linkmap' ), 'utf8' )) : {};
39 const linkmap = Object. fromEntries (Object. entries (rawLinkmap). map (([ u , v ]) => [ normU (u), v]));
40 const mapped = ( u ) => linkmap[ normU (u)] || null ;
41
42 // "self" in --pages means this very page: it needs --page-id, never becomes the literal id "self"
43 for ( const [ u , v ] of Object. entries (pageMap)) if (v === 'self' ) {
44 if ( ! opt ( 'page-id' )) { console. error ( `--pages maps ${ u } to "self": pass --page-id <this page's Harmony pageId>` ); process. exit ( 2 ); }
45 pageMap[u] = opt ( 'page-id' );
46 }
47 const decorative = ( opt ( 'decorative' , '' ) || '' ). split ( ',' ). filter (Boolean);
48 const review = [];
49 // Mobile grouping thresholds (desktop px at the measured width). The defaults come from a fixed-width,
50 // centred 1280 layout with a top nav, a header band, a main column and a right-hand sidebar; another
51 // theme needs its own numbers: pass --layout and read the `review` item that says defaults were used.
52 const L = { navMaxY: 60 , mainLeftMin: 200 , mainLeftMax: 400 , mainTopMin: 300 , sideMinX: 860 , footHeight: 160 , ... JSON . parse ( opt ( 'layout' , '{}' )) };
53
54 // ---- helpers ------------------------------------------------------------------------------
55 const toSource = ( u ) => (u && localOrigin && sourceOrigin ? u. split (localOrigin). join (sourceOrigin) : u);
56 const plain = ( h ) => (h || '' ). replace ( /<br \s * \/ ? >/ g , ' ' ). replace ( /< [ ^ >] + >/ g , '' ). replace ( / |  / g , ' ' ). replace ( /&/ g , '&' ). replace ( /’ | ’/ g , "'" ). replace ( / \s + / g , ' ' ). trim (). toLowerCase ();
57 const hex = ( c ) => { if ( / ^ # [0-9a-f] {6}$ / i . test ( String (c). trim ())) return String (c). trim (). toUpperCase (); const m = String (c). match ( / \d + ( \. \d + ) ? / g ); if ( ! m) return c; const [ r , g , b , a ] = m. map (Number); const h = '#' + [r, g, b]. map (( v ) => v. toString ( 16 ). padStart ( 2 , '0' )). join ( '' ). toUpperCase (); return a !== undefined && a < 1 ? `rgba(${ r },${ g },${ b },${ a })` : h; };
58 const px = ( v ) => parseFloat (v);
59 const fontFor = ( family ) => {
60 const first = String (family). split ( ',' )[ 0 ]. replace ( / ["'] / g , '' ). trim ();
61 const hit = Object. entries (fontLedger). find (([ k ]) => k. toLowerCase () === first. toLowerCase ());
62 if (hit) return hit[ 1 ];
63 const generic = { courier: 'courier new' , 'courier new' : 'courier new' , arial: 'arial' , helvetica: 'helvetica-w01-roman' , georgia: 'georgia' , 'times new roman' : 'times new roman' };
64 if (generic[first. toLowerCase ()]) return generic[first. toLowerCase ()];
65 review. push ({ font: first, why: 'no Harmony family for this source font: upload it (Upload fonts dialog) and add it to the font ledger' });
66 return first. toLowerCase ();
67 };
68 const linkFor = ( href ) => {
69 if ( ! href) return null ;
70 let u = toSource (href);
71 const lm = mapped (u); if (lm?.link) return { ... lm.link, ... (lm.unbuilt ? { unbuilt: { url: stripTracking (u), reason: lm.unbuilt } } : {}) };
72 u = stripTracking (u); // tracking parameters change per visit
73 const pid = pageMap[u] ?? pageMap[u. replace ( / \/ $ / , '' )] ?? pageMap[u + '/' ];
74 if (pid) return { type: 'PageLink' , pageId: pid, target: '_self' };
75 if (u. startsWith ( 'mailto:' )) return { type: 'EmailLink' , recipient: u. slice ( 7 ). split ( '?' )[ 0 ] };
76 if (u. startsWith ( 'tel:' )) return { type: 'PhoneLink' , phoneNumber: u. slice ( 4 ) };
77 const sameSite = sourceOrigin && u. startsWith (sourceOrigin);
78 return { type: 'ExternalLink' , url: u, target: sameSite ? '_self' : '_blank' , ... (sameSite ? { sourcePage: true } : {}) };
79 };
80 const align = ( a ) => ({ left: 'start' , start: 'start' , right: 'end' , end: 'end' , center: 'center' , justify: 'justify' }[a] || 'start' );
81 // a recipe is reused when the same words (text) or the same file (image) exist on an exported page
82 const recipeText = ( txt , size , color ) => {
83 // same words AND the same look: the recipe's font size within 1px and the same colour
84 const look = ( r ) => { const st = Object. assign ({}, ... (r.styles || []). map (( e ) => e.props)); return { size: px (st.fontSize), color: hex (st.color) }; };
85 const cands = recipes. filter (( r ) => (r.html ? plain (r.html) : r.content?.label ? plain (r.content.label) : null ) === txt)
86 . filter (( r ) => { const l = look (r); return Math. abs (l.size - size) <= 1 && l.color === hex (color); });
87 return cands[ 0 ] || null ;
88 };
89 const recipeImage = ( srcUrl ) => { const f = srcUrl. split ( '/' ). pop (). split ( '?' )[ 0 ]; return recipes. find (( r ) => r.type === 'wixEditorElements.Image' && (r.content?.image?.name || '' ). endsWith (f)) || null ; };
90 const KEEP = { // the style keys a copy writes; everything else stays the component's default
91 'wixEditorElements.WRichText' : { root: [ 'color' , 'textAlign' , 'fontFamily' , 'fontSize' , 'fontStyle' , 'fontWeight' , 'lineHeight' , 'letterSpacing' ] },
92 'wixEditorElements.Button' : { root: [ 'background' , 'backgroundColor' , 'backgroundImage' , 'boxShadow' , 'paddingTop' , 'paddingBottom' , 'paddingInlineStart' , 'paddingInlineEnd' , 'borderTopWidth' , 'borderBottomWidth' , 'borderInlineStartWidth' , 'borderInlineEndWidth' , 'borderStartStartRadius' , 'borderStartEndRadius' , 'borderEndStartRadius' , 'borderEndEndRadius' ], label: [ 'color' , 'textAlign' , 'fontFamily' , 'fontSize' , 'fontWeight' , 'fontStyle' , 'lineHeight' , 'letterSpacing' , 'textDecorationLine' ] },
93 'wixEditorElements.Image' : { root: [ 'opacity' ] },
94 };
95 const recipeStyles = ( r ) => {
96 const keep = KEEP [r.type] || {}; const out = {};
97 for ( const e of r.styles || []) { const k = e.path. join ( '/' ) || 'root' ; if ( ! keep[k]) continue ; const props = Object. fromEntries (Object. entries (e.props). filter (([ p , v ]) => keep[k]. includes (p) && ! String (v). startsWith ( 'var(' ))); if (Object. keys (props). length ) out[k] = props; }
98 return out;
99 };
100 const textStyles = ( t ) => {
101 const size = px (t.size); const pitch = t.linePitch || px (t.lineHeight) || size * 1.2 ;
102 const ls = t.letterSpacing === 'normal' ? 'normal' : `${ Math . round (( px ( t . letterSpacing ) / size ) * 1000 ) / 1000 }em` ;
103 return { root: { fontFamily: fontFor (t.font), fontSize: `${ size }px` , lineHeight: `${ Math . round (( pitch / size ) * 10000 ) / 10000 }em` , fontWeight: String (t.weight), fontStyle: t.style, color: hex (t.color), textAlign: align (t.align), letterSpacing: ls } };
104 };
105 // single-line left-aligned text gets its painted width plus slack, so it never wraps early;
106 // multi-line text keeps the source block (or float run) width so it wraps where the source wraps
107 const textBox = ( t , seg ) => {
108 const pitch = t.linePitch || px (t.lineHeight) || px (t.size) * 1.2 ;
109 const b = seg ? seg.box : t.block; const lines = seg ? seg.lines : t.lines || 1 ;
110 const y = Math. round (seg ? seg.lineTop : t.lineTop);
111 if (lines <= 1 && ! seg) { // one line: its painted width plus slack, anchored the way it is aligned
112 const g = t.glyphBox || t.textBox; // the words, not an inline icon beside them
113 const w = Math. min (b.w, g.w + 12 ), a = align (t.align);
114 const x = a === 'center' ? Math. round (g.x + g.w / 2 - w / 2 ) : a === 'end' ? g.x + g.w + 6 - w : g.x;
115 return { x: Math. max (b.x, Math. min (x, b.x + b.w - w)), y, width: w, height: Math. round (pitch) };
116 }
117 return { x: b.x, y, width: b.w, height: Math. round (lines * pitch) };
118 };
119
120 // a click area that paints nothing
121 const TRANSPARENT = { root: { backgroundColor: 'rgba(0,0,0,0)' , background: 'rgba(0,0,0,0)' , boxShadow: 'none' , borderTopWidth: '0px' , borderBottomWidth: '0px' , borderInlineStartWidth: '0px' , borderInlineEndWidth: '0px' , paddingTop: '0px' , paddingBottom: '0px' , paddingInlineStart: '0px' , paddingInlineEnd: '0px' }, label: { color: 'rgba(0,0,0,0)' } };
122
123 // ---- components ---------------------------------------------------------------------------
124 const comps = []; let n = 0 ;
125 const key = ( kind , label ) => `${ kind }-${ String ( ++ n ). padStart ( 2 , '0' ) }-${ plain ( label ). replace ( / [ ^ a-z0-9] + / g , '-' ). slice ( 0 , 24 ). replace ( /- $ / , '' ) }` ;
126
127 for ( const f of src.fixed) {
128 const r = recipes. find (( x ) => x.pin && plain (x.content?.label) === plain (f.text));
129 if ( ! r) { review. push ({ fixed: f.text, why: 'floating element without a recipe: build a pinned Button from fixed[] by hand once, export it, and pass it as --recipes' }); continue ; }
130 comps. push ({ key: key ( 'pinned' , f.text), kind: 'pinned' , type: r.type, label: r.content.label, link: linkFor (f.href), styles: recipeStyles (r), pin: { position: r.pin.position, offsetPx: { horizontal: r.pin.offsetPx.horizontal, vertical: f.bottomOffset } }, desktop: { x: f.box.x, y: f.box.y, width: f.box.w, height: f.box.h }, from: `recipe:${ r . label }` , recipeId: r.id });
131 }
132
133 for ( const t of src.texts) {
134 if (src.fixed. some (( f ) => plain (f.text) === plain (t.text))) continue ; // the floating element's own words
135 if (t.hiddenText) {
136 if ( ! t.links. length ) continue ;
137 const r = recipes. find (( x ) => x.type === 'wixEditorElements.Button' && /hotspot/ . test (x.label));
138 comps. push ({ key: key ( 'hotspot' , t.text), kind: 'hotspot' , type: 'wixEditorElements.Button' , label: t.text. trim (), link: linkFor (t.links[ 0 ]), styles: r ? recipeStyles (r) : TRANSPARENT , desktop: { x: t.block.x, y: t.block.y, width: t.block.w, height: t.block.h }, from: r ? `recipe:${ r . label }` : 'measured' , recipeId: r ? r.id : null , note: 'click area over artwork in the plate (image-replacement link on the source)' });
139 continue ;
140 }
141 if (t.occluded) { review. push ({ text: t.text. slice ( 0 , 40 ), why: 'covered by another element on the source: not copied' }); continue ; }
142 const parts = t.segments || [ null ];
143 for ( const seg of parts) {
144 let rich = toSource (seg ? seg.rich : t.rich);
145 if ( ! plain (rich)) continue ;
146 const r = ! seg && recipeText ( plain (rich), px (t.size), t.color);
147 if (r && r.type === 'wixEditorElements.Button' ) { // the exported page used a Button for this text (nav links)
148 comps. push ({ key: key ( 'button' , t.text), kind: 'button' , type: r.type, label: r.content.label, link: linkFor ((t.links[ 0 ] || null )), styles: recipeStyles (r), desktop: { x: t.block.x, y: t.block.y, width: t.block.w, height: t.block.h }, from: `recipe:${ r . label }` , recipeId: r.id });
149 continue ;
150 }
151 // links in text stay INLINE <a href>: links.set on a rich text rewrites it (root becomes <h1>,
152 // an underline span is added). A text that must reach a Harmony page is listed for review.
153 const link = null ;
154 // a text that is itself inside a link (nav item, "show all") has no <a> in its own markup: wrap it, or the link is lost
155 if (t.links?. length && ! /<a href=/ . test (rich) && ! seg) rich = `<a href="${ toSource ( t . links [ 0 ]) }">${ rich }</a>` ;
156 const unbuiltInline = [];
157 rich = rich. replace ( /<a href="( [ ^ "] + )"/ g , ( all , h ) => { const lm = mapped (h); const to = lm?.inline || (lm?.link?.type === 'ExternalLink' ? lm.link.url : null ); if (lm?.unbuilt) unbuiltInline. push ({ url: stripTracking ( toSource (h)), reason: lm.unbuilt, standIn: to || 'unchanged (still the source URL)' }); return to ? `<a href="${ to . replace ( /&/ g , '&' ) }"` : all; });
158 for ( const m of rich. matchAll ( /<a href="( [ ^ "] + )"/ g )) { const l = linkFor (m[ 1 ]); if (l?.type === 'PageLink' ) review. push ({ text: plain (rich). slice ( 0 , 40 ), why: `inline link to a Harmony page (${ l . pageId }): rich text keeps the source URL; use a Button if it must be a page link` }); }
159 const tag = / ^ h [1-6] $ / i . test (t.tag || '' ) ? t.tag. toLowerCase () : 'p' ;
160 const tk = key ( 'text' , seg ? seg.rich : t.text);
161 comps. push ({ key: tk, kind: 'text' , type: 'wixEditorElements.WRichText' , html: `<${ tag }>${ rich }</${ tag }>` , link, ... (unbuiltInline. length ? { unbuiltInline } : {}), styles: r ? recipeStyles (r) : textStyles (t), desktop: textBox (t, seg), lines: seg ? seg.lines : t.lines, from: r ? `recipe:${ r . label }` : 'measured' , recipeId: r ? r.id : null , ... (seg ? { note: `run ${ parts . indexOf ( seg ) + 1 }/${ parts . length } of a paragraph that wraps around a float` , ... (parts. indexOf (seg) > 0 ? { continues: true } : {}) } : {}) });
162 }
163 }
164
165 for ( const im of src.images) {
166 if (decorative. some (( d ) => im.src. includes (d))) continue ;
167 const r = recipeImage (im.src);
168 comps. push ({ key: key ( 'image' , im.src. split ( '/' ). pop ()), kind: 'image' , type: 'wixEditorElements.Image' , image: r ? { uri: r.content.image.uri, name: r.content.image.name, width: r.content.image.width, height: r.content.image.height, alt: im.alt || r.content.image.alt || '' } : { source: toSource (im.src). replace ( /- \d + x \d + ( \. \w + ) $ / , '$1' ), sourceRendered: toSource (im.src), name: im.src. split ( '/' ). pop (), alt: im.alt || '' , width: im.box.w, height: im.box.h }, displayMode: 'fitWidth' , link: linkFor (im.href), desktop: { x: im.box.x, y: im.box.y, width: im.box.w, height: im.box.h }, from: r ? `recipe:${ r . label }` : 'measured' , recipeId: r ? r.id : null });
169 }
170
171 // ---- elements Harmony supplies itself (--native native.json) -------------------------------------
172 // [{ "key": "cart", "type": "checkout_Orders.CartIcon_legacy", "desktop": { x, y, width, height }, "styles": {...} }]
173 // A cart icon or login a store needs: added with the page in the same deploy, never by hand afterwards (every
174 // add or remove after the deploy lets the editor squeeze the page's blank bands and shifts the whole layout).
175 for ( const nv of opt ( 'native' ) ? JSON . parse (fs. readFileSync ( opt ( 'native' ), 'utf8' )) : []) comps. push ({ key: `native-${ nv . key }` , kind: 'native' , type: nv.type, label: nv.key, link: null , styles: nv.styles || {}, desktop: nv.desktop, from: 'native' , recipeId: null });
176
177 // ---- every source link is a link in the copy ------------------------------------------------
178 // Text and image links are set above. Any other visible link (icons painted in the plate, the logo, a tile, a
179 // social icon) becomes a transparent click area over its box, so no link is dropped.
180 const overlap = ( a , b ) => { const w = Math. min (a.x + a.width, b.x + b.width) - Math. max (a.x, b.x), h = Math. min (a.y + a.height, b.y + b.height) - Math. max (a.y, b.y); return w > 0 && h > 0 ? (w * h) / (b.width * b.height) : 0 ; }; // share of b (the source link's box) that a covers
181 const sameLink = ( a , b ) => a && b && a.type === b.type && (a.url || a.pageId || a.phoneNumber || a.recipient) === (b.url || b.pageId || b.phoneNumber || b.recipient) && (a.innerRoute || '' ) === (b.innerRoute || '' );
182 const seen = new Set ();
183 for ( const a of src.anchors || []) {
184 const href = toSource (a.href); const bx = { x: a.box.x, y: a.box.y, width: a.box.w, height: a.box.h };
185 if (bx.x < 0 || bx.x + bx.width > src.width + 1 || / ^ javascript:/ i . test (href) || /demandware \. store | \/ on \/ demandware/ . test (href)) continue ; // off the page (carousel clones) or an app action, not a page link
186 if ((src.fixed || []). some (( f ) => overlap ({ x: f.box.x, y: f.box.y, width: f.box.w, height: f.box.h }, bx) >= 0.5 )) continue ; // a floating element (chat pill...): pinned by hand/recipe, listed in review, never a click area over the page
187 const dupe = `${ normU ( href ) }@${ bx . x },${ bx . y }` ; if (seen. has (dupe)) continue ; seen. add (dupe);
188 const want = linkFor (href); if ( ! want) continue ;
189 const inl = mapped (href); const inlineUrl = inl?.inline || inl?.link?.url || href;
190 const covered = comps. some (( c ) => overlap (c.desktop, bx) >= 0.5 && (c.kind === 'native' || sameLink (c.link, want) || (c.kind === 'text' && (c.html. includes ( `href="${ inlineUrl . replace ( /&/ g , '&' ) }"` ) || c.html. includes ( `href="${ href }"` )))));
191 if (covered) continue ;
192 comps. push ({ key: key ( 'hotspot' , a.text || href), kind: 'hotspot' , type: 'wixEditorElements.Button' , label: (a.text || 'link' ). slice ( 0 , 40 ), link: want, styles: TRANSPARENT , desktop: bx, from: 'measured' , recipeId: null , note: 'click area over a source link that is not text or an image' });
193 }
194
195 for ( const u of src.unsupported || []) review. push ({ unsupported: `${ u . tag }${ u . src ? ' ' + u . src : ''} at x${ u . box . x } y${ u . box . y } ${ u . box . w }x${ u . box . h }` , why: 'not rebuilt by this method (only text, images, buttons and the plate): decide by hand, e.g. a Harmony app/element, a link, or leave it out and tell the customer' });
196 if ( ! opt ( 'layout' )) review. push ({ layout: 'default mobile grouping thresholds' , why: 'tuned for a fixed-width 1280 layout with nav, header band, main column and right sidebar: check the mobile render, or pass --layout' });
197 // ---- mobile: one readable column in reading order, reusing an exported page's mobile boxes --
198 const W = 390 , M = 24 , CW = W - 2 * M ;
199 const recipeOf = ( c ) => (c.recipeId ? recipes. find (( r ) => r.id === c.recipeId) : null );
200 const top = Math. min ( ... src.texts. filter (( t ) => / ^ (start | left) $ / . test (t.align) && t.block.x > L .mainLeftMin && t.block.x < L .mainLeftMax && t.block.y > L .mainTopMin). map (( t ) => t.block.y), 1e9 ); // first main-column line
201 const group = ( c ) => {
202 const d = c.desktop;
203 if (c.kind === 'pinned' ) return 'pinned' ;
204 if (d.y < L .navMaxY) return 'nav' ;
205 if (d.y + d.height <= top) return 'head' ;
206 if (d.x >= L .sideMinX) return 'side' ;
207 if (d.y > src.page.height - L .footHeight) return 'foot' ;
208 return 'main' ;
209 };
210 for ( const c of comps) {
211 c.mobile = { group: group (c) };
212 const r = recipeOf (c);
213 if (c.mobile.group === 'pinned' ) continue ;
214 if (c.kind === 'hotspot' ) { c.mobile.hidden = true ; continue ; }
215 if (c.kind === 'native' ) { c.mobile.width = c.desktop.width; c.mobile.x = W - M - c.desktop.width; continue ; } // keeps its own size: it is not a full-width block
216 if (r && r.mobile && [ 'nav' , 'head' ]. includes (c.mobile.group)) { c.mobile.box = r.mobile; continue ; } // the same chrome as the exported page
217 if (r?.mobile && c.mobile.group === 'side' ) { c.mobile.x = r.mobile.x; c.mobile.width = r.mobile.width; continue ; } // same arrangement as the exported page
218 const w = c.kind === 'image' ? Math. min (c.desktop.width, CW ) : CW ;
219 c.mobile.width = w;
220 c.mobile.x = c.kind === 'image' ? Math. round (( W - w) / 2 ) : M ;
221 }
222 const ordered = [ 'nav' , 'head' , 'main' , 'side' , 'foot' ];
223 comps. forEach (( c ) => { c.mobile.order = ordered. indexOf (c.mobile.group) * 100000 + Math. round (c.desktop.y / 10 ) * 10 + c.desktop.x / 10000 ; }); // rows within 10 px read left to right
224 // icons that sit side by side on desktop stay in one row on mobile
225 for ( const c of comps) { const row = comps. filter (( o ) => o !== c && o.mobile.group === c.mobile.group && o.kind === 'image' && c.kind === 'image' && Math. abs (o.desktop.y - c.desktop.y) < 8 ); if (row. length && [c, ... row]. reduce (( n , o ) => n + o.mobile.width, 0 ) + 12 * row. length <= CW ) c.mobile.row = Math. min ( ... [c, ... row]. map (( o ) => Math. round (o.desktop.y))); } // only when the row really fits the mobile width: large cards stack
226
227 // a row that stays a row on mobile: spread its images evenly across the content width, in desktop order
228 const rows = new Map (); for ( const c of comps) if (c.mobile.row != null ) rows. set ( `${ c . mobile . group }:${ c . mobile . row }` , [ ... (rows. get ( `${ c . mobile . group }:${ c . mobile . row }` ) || []), c]);
229 for ( const items of rows. values ()) { items. sort (( a , b ) => a.desktop.x - b.desktop.x); const sum = items. reduce (( n , o ) => n + o.mobile.width, 0 ); const gap = items. length > 1 ? ( CW - sum) / (items. length - 1 ) : 0 ; let x = M ; for ( const o of items) { o.mobile.x = Math. round (x); x += o.mobile.width + gap; } }
230
231 // the page colour: an exported page's section colour when given, else the body's
232 const exportedSection = optAll ( 'recipes' ). map (( f ) => JSON . parse (fs. readFileSync (f, 'utf8' )).section). find (Boolean);
233 const sectionColor = exportedSection?.background?. find (( f ) => f.key === 'backgroundColor' )?.value?.raw || hex ((src.backgrounds. find (( b ) => b.tag === 'BODY' ) || {}).color || '#FFFFFF' );
234 // mobile band colours are sampled from the plate by check-description
235 const out = {
236 schema: 'harmony-page-description/1' ,
237 generatedAt: new Date (). toISOString (),
238 source: { url: toSource (src.url), measured: src.url, width: src.width, height: src.page.height, centered: src.page.centered },
239 page: { name: opt ( 'name' , null ) },
240 layout: L ,
241 section: { desktop: { width: src.width, height: src.page.height }, background: { plate: null , fittingType: 'fill' , backgroundColor: sectionColor }, mobile: { width: W , margin: M , gap: 12 , groupGap: 28 , bands: {} } },
242 components: comps,
243 openItems: openItems ({ components: comps, source: { url: toSource (src.url) } }), // links still pointing at the source site: the cutover hand-off
244 pendingItems: pendingItems ({ components: comps }), // pages the copy still lacks (their links point at a stand-in page): the cutover hand-off
245 review,
246 };
247 fs. writeFileSync (outFile, JSON . stringify (out, null , 1 ));
248 const count = ( k ) => comps. filter (( c ) => c.kind === k). length ;
249 console. log ( JSON . stringify ({ out: outFile, components: comps. length , text: count ( 'text' ), image: count ( 'image' ), button: count ( 'button' ), hotspot: count ( 'hotspot' ), pinned: count ( 'pinned' ), fromRecipes: comps. filter (( c ) => c.from. startsWith ( 'recipe' )). length , newMedia: comps. filter (( c ) => c.image && ! c.image.uri). map (( c ) => c.image.name), review }));