Setting the file. One moment.
Measure Source · Wix Harmony Editor · wix/skills · Skills Docs
ContentsBack to the top of the page scripts/copy/measure-source.mjs
scripts/copy/ measure-source.mjs
JavaScript · 211 lines · 20 KB
14 // alignment, inner HTML, explicit <br> breaks and links, plus:
15 // rich Harmony-ready rich-text HTML: links kept, <br> kept, images and
16 // nested blocks dropped, and every run whose colour, weight,
17 // style, decoration, family or size differs from the text's own
18 // base style (font/size/color below) as an
19 // inline <span style> (sizes as em, so they scale)
20 // segments only when the text wraps around a float (its lines start at
21 // different x): one {rich, box, lineTop, lines} per run of lines
22 // sharing a left edge, box width = the space the lines wrap in
23 // lines, linePitch painted line count and the real distance between lines
24 // (can differ from lineHeight when inline content is bigger)
25 // occluded true when something else is painted over all of the first line:
26 // usually not copied
27 // hiddenText the words are not painted (image replacement, text-indent
28 // -9999px): with a link the box is a click area over artwork
29 // (copy as a transparent link button), without one skip it
30 // fixed position:fixed elements (floating buttons, chat widgets) incl. shadow DOM
31 // unsupported iframes, video/audio, canvas, forms and form controls, inline svg: not rebuilt by
32 // this method; build-description turns each into a `review` item
33 //
34 // Read only. It never logs in and never sends anything to the source.
35 import { chromium } from 'playwright' ;
36 import fs from 'node:fs' ;
37
38 const args = process.argv. slice ( 2 );
39 const opt = ( name , dflt ) => { const i = args. indexOf ( `--${ name }` ); return i >= 0 ? args[i + 1 ] : dflt; };
40 const [ url , out ] = args. filter (( a , i ) => ! a. startsWith ( '--' ) && ! (i > 0 && args[i - 1 ]. startsWith ( '--' )));
41 if ( ! url || ! out) { console. error ( 'usage: measure-source.mjs <url> <out.json> [--width 1280] [--height 900]' ); process. exit ( 2 ); }
42 const width = Number ( opt ( 'width' , 1280 ));
43 const height = Number ( opt ( 'height' , 900 ));
44
45 const browser = await chromium. launch ();
46
47 async function measure ( w ) {
48 const page = await browser. newPage ({ viewport: { width: w, height } });
49 await page. goto (url, { waitUntil: 'networkidle' , timeout: 90000 });
50 await page. waitForTimeout ( 1500 );
51 const data = await page. evaluate (() => {
52 const R = ( r ) => ({ x: Math. round (r.x), y: Math. round (r.y + scrollY), w: Math. round (r.width), h: Math. round (r.height) });
53 const visible = ( e ) => { const s = getComputedStyle (e); return s.display !== 'none' && s.visibility !== 'hidden' && + s.opacity > 0 ; };
54 const backgrounds = [], images = [], texts = [], fixed = [], unsupported = [];
55 for ( const e of document. querySelectorAll ( 'body *' )) {
56 if ( ! visible (e)) continue ;
57 const s = getComputedStyle (e), r = e. getBoundingClientRect ();
58 if (r.width < 1 || r.height < 1 ) continue ;
59 const hasImg = s.backgroundImage && s.backgroundImage !== 'none' ;
60 const hasColor = ! /rgba \( 0, 0, 0, 0 \) | transparent/ . test (s.backgroundColor);
61 if (hasImg || hasColor) backgrounds. push ({ tag: e.tagName, id: e.id || null , cls: String (e.className || '' ). slice ( 0 , 80 ), box: R (r), image: hasImg ? s.backgroundImage : null , repeat: s.backgroundRepeat, position: s.backgroundPosition, size: s.backgroundSize, color: s.backgroundColor, radius: s.borderRadius });
62 // things this copy method does not rebuild (it only knows text, images, buttons and the plate): listed so
63 // nothing is dropped silently. Hidden/aria-hidden tracking pixels are skipped by visible() above.
64 if ( / ^ (IFRAME | VIDEO | AUDIO | CANVAS | OBJECT | EMBED | FORM | INPUT | SELECT | TEXTAREA | SVG) $ / i . test (e.tagName) && ! (e.tagName. toUpperCase () === 'INPUT' && e.type === 'hidden' ) && ! e. closest ( 'svg:not(:scope)' ) && ! (e.tagName. toUpperCase () === 'SVG' && r.width < 24 && r.height < 24 )) unsupported. push ({ tag: e.tagName. toLowerCase (), src: e.currentSrc || e.src || e.action || null , box: R (r) });
65 if (e.tagName === 'IMG' && e.dataset.embedSrc) { unsupported. push ({ tag: 'iframe' , src: e.dataset.embedSrc, box: R (r) }); continue ; } // the offline copy swapped an iframe for a snapshot image: still an embed, not a picture
66 if (e.tagName === 'IMG' ) images. push ({ src: e.currentSrc || e.src, natural: [e.naturalWidth, e.naturalHeight], box: R (r), alt: e.alt || '' , href: e. closest ( 'a' ) ? e. closest ( 'a' ).href : null });
67 }
68 // floats only push lines of text in the same block formatting context (BFC)
69 const makesBfc = ( e ) => { const s = getComputedStyle (e); return e === document.documentElement || s.float !== 'none' || /absolute | fixed/ . test (s.position) || ! /visible | clip/ . test (s.overflowX + s.overflowY) && s.overflow !== 'visible' || /inline-block | flow-root | table-cell | table-caption | flex | grid/ . test (s.display) || (e.parentElement && /flex | grid/ . test ( getComputedStyle (e.parentElement).display)); };
70 const bfcOf = ( e ) => { for ( let p = e; p; p = p.parentElement) if ( makesBfc (p)) return p; return document.documentElement; };
71 const floats = [ ... document. querySelectorAll ( 'body *' )]. filter (( e ) => { const s = getComputedStyle (e); return (s.float === 'left' || s.float === 'right' ) && visible (e); })
72 . map (( e ) => ({ el: e, bfc: bfcOf (e.parentElement), side: getComputedStyle (e).float, box : () => { const q = e. getBoundingClientRect (), s = getComputedStyle (e); return { left: q.left - parseFloat (s.marginLeft), right: q.right + parseFloat (s.marginRight), top: q.top - parseFloat (s.marginTop), bottom: q.bottom + parseFloat (s.marginBottom) }; } }));
73 const seen = new Set ();
74 const tw = document. createTreeWalker (document.body, NodeFilter. SHOW_TEXT );
75 while (tw. nextNode ()) {
76 const n = tw.currentNode;
77 if ( ! n.textContent. trim ()) continue ;
78 const el = n.parentElement;
79 if ( ! el || [ 'SCRIPT' , 'STYLE' , 'NOSCRIPT' ]. includes (el.tagName) || ! visible (el)) continue ;
80 let block = el; while (block && getComputedStyle (block).display === 'inline' ) block = block.parentElement;
81 if ( ! block || seen. has (block)) continue ; seen. add (block);
82 const bs = getComputedStyle (block), br = block. getBoundingClientRect ();
83 if (br.width < 1 || br.right < 0 || br.left > innerWidth) continue ; // off-screen (e.g. hidden menu items)
84 const rg = document. createRange (); rg. selectNodeContents (block); const tr = rg. getBoundingClientRect ();
85 const ts = getComputedStyle (el);
86 const lh = parseFloat (ts.lineHeight) || parseFloat (ts.fontSize) * 1.2 ;
87 const rects = [ ... rg. getClientRects ()]. filter (( q ) => q.width > 0 );
88 const firstLine = rects. length ? rects[ 0 ] : tr;
89 // a glyph rect is the font's content area, centred on its line box
90 const lineTop = Math. round ((firstLine.top + scrollY + (firstLine.height - lh) / 2 ) * 10 ) / 10 ;
91 // ---- rich HTML, line runs (floats) and occlusion -------------------------------
92 const hex = ( c ) => { const m = 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; };
93 const escT = ( t ) => t. replace ( /&/ g , '&' ). replace ( /</ g , '<' ). replace ( />/ g , '>' ). replace ( / \u00a0 / g , ' ' );
94 const inBlock = ( n ) => { for ( let p = n.parentElement; p && p !== block; p = p.parentElement) if ( ! / ^ inline/ . test ( getComputedStyle (p).display)) return false ; return true ; };
95 // the BASE style is the one most characters are painted in; every other run becomes a flat
96 // <span style> relative to it (sizes in em), links stay <a>, nested blocks are their own entry
97 const runKey = ( c ) => [c.color, c.fontWeight, c.fontStyle, c.textDecorationLine, c.fontFamily, c.fontSize]. join ( '|' );
98 const counts = new Map (); const twc = document. createTreeWalker (block, NodeFilter. SHOW_TEXT );
99 while (twc. nextNode ()) { const tn = twc.currentNode; if ( ! tn.textContent. trim () || ! inBlock (tn)) continue ; const c = getComputedStyle (tn.parentElement); const k = runKey (c); const e = counts. get (k) || { n: 0 , el: tn.parentElement }; e.n += tn.textContent. trim (). length ; counts. set (k, e); }
100 const baseEl = counts.size ? [ ... counts. values ()]. sort (( x , y ) => y.n - x.n)[ 0 ].el : el;
101 const base = getComputedStyle (baseEl);
102 const diff = ( cs ) => { const st = [];
103 if (cs.color !== base.color) st. push ( `color:${ hex ( cs . color ) }` );
104 if (cs.fontWeight !== base.fontWeight) st. push ( `font-weight:${ cs . fontWeight }` );
105 if (cs.fontStyle !== base.fontStyle) st. push ( `font-style:${ cs . fontStyle }` );
106 if (cs.textDecorationLine !== base.textDecorationLine) st. push ( `text-decoration:${ cs . textDecorationLine }` );
107 if (cs.fontFamily !== base.fontFamily) st. push ( `font-family:${ cs . fontFamily . replace ( /"/ g , "'" ) }` );
108 if (cs.fontSize !== base.fontSize) st. push ( `font-size:${ ( parseFloat ( cs . fontSize ) / parseFloat ( base . fontSize )). toFixed ( 3 ) }em` );
109 return st. join ( ';' ); };
110 const rich = ( node , R ) => {
111 let o = '' ;
112 for ( const n of node.childNodes) {
113 if ( R && ! R . intersectsNode (n)) continue ;
114 if (n.nodeType === 3 ) { const t = n.textContent; const s0 = R && n === R .startContainer ? R .startOffset : 0 ; const e0 = R && n === R .endContainer ? R .endOffset : t. length ;
115 const w = escT (t. slice (s0, e0). replace ( / [\n\t ] + / g , ' ' )); const d = w. trim () ? diff ( getComputedStyle (n.parentElement)) : '' ; o += d ? `<span style="${ d }">${ w }</span>` : w; continue ; }
116 if (n.nodeType !== 1 ) continue ;
117 const tag = n.tagName; if ([ 'IMG' , 'SCRIPT' , 'STYLE' , 'NOSCRIPT' , 'SVG' , 'IFRAME' ]. includes (tag)) continue ;
118 if (tag === 'BR' ) { o += '<br>' ; continue ; }
119 const cs = getComputedStyle (n); if (cs.display === 'none' || ! / ^ inline/ . test (cs.display)) continue ;
120 const inner = rich (n, R );
121 o += tag === 'A' && n. getAttribute ( 'href' ) ? `<a href="${ n . href }">${ inner }</a>` : inner;
122 }
123 return o;
124 };
125 const tidy = ( h ) => h. replace ( / ^ ( \s | | <br>) +| ( \s | | <br>) +$ / g , '' );
126 const bcs = getComputedStyle (block);
127 // one entry per visual line: where it starts (text node + offset), its left and top
128 const lineStarts = []; const tw2 = document. createTreeWalker (block, NodeFilter. SHOW_TEXT );
129 const lhB = parseFloat (bcs.lineHeight) || parseFloat (bcs.fontSize) * 1.2 ;
130 while (tw2. nextNode ()) { const tn = tw2.currentNode; if ( ! inBlock (tn)) continue ; const rr = document. createRange ();
131 for ( let i = 0 ; i < tn.textContent. length ; i ++ ) { if ( / \s / . test (tn.textContent[i])) continue ; rr. setStart (tn, i); rr. setEnd (tn, i + 1 ); const q = rr. getBoundingClientRect (); if ( ! q.width) continue ;
132 const last = lineStarts[lineStarts. length - 1 ]; if ( ! last || q.top > last.top + lhB / 2 ) lineStarts. push ({ node: tn, offset: i, left: q.left, top: q.top, h: q.height }); } }
133 let segments = null ;
134 // image replacement (text-indent:-9999px etc.): the words are not painted, the box is a click area
135 const hiddenText = lineStarts. length > 0 && (lineStarts[ 0 ].left > br.right + 50 || lineStarts[ 0 ].left < br.left - 50 );
136 const leftAligned = / ^ (start | left | justify) $ / . test (bcs.textAlign) && bcs.direction !== 'rtl' ;
137 if ( ! hiddenText && leftAligned && lineStarts. length ) {
138 // the space each line wraps in: the block's content box minus every float beside the line
139 const contentLeft = br.left + parseFloat (bcs.paddingLeft) + parseFloat (bcs.borderLeftWidth);
140 const contentRight = br.right - parseFloat (bcs.paddingRight) - parseFloat (bcs.borderRightWidth);
141 for ( const l of lineStarts) {
142 l.availL = contentLeft; l.availR = contentRight;
143 const myBfc = makesBfc (block) ? block : bfcOf (block);
144 // a float pushes only the lines that come after it in the document
145 for ( const fl of floats) { if (fl.el. contains (block) || fl.bfc !== myBfc || ! (fl.el. compareDocumentPosition (l.node) & Node. DOCUMENT_POSITION_FOLLOWING )) continue ; const q = fl. box (); if (q.bottom <= l.top + 1 || q.top >= l.top + lhB - 1 ) continue ;
146 if (fl.side === 'left' && q.right > l.availL && q.left < contentRight) l.availL = Math. max (l.availL, q.right);
147 if (fl.side === 'right' && q.left < l.availR && q.right > contentLeft) l.availR = Math. min (l.availR, q.left); }
148 }
149 const groups = []; for ( const l of lineStarts) { const g = groups[groups. length - 1 ]; if (g && Math. abs (g.availL - l.availL) < 4 && Math. abs (g.availR - l.availR) < 4 ) g.lines. push (l); else groups. push ({ availL: l.availL, availR: l.availR, left: l.left, lines: [l] }); }
150 // runs of lines that wrap in different spaces (floats beside some lines), or a single run narrowed by a float
151 if (groups. length > 1 || groups[ 0 ].availL > contentLeft + 4 || groups[ 0 ].availR < contentRight - 4 ) {
152 segments = groups. map (( g , gi ) => { const R = document. createRange (); if (gi === 0 ) R . setStart (block, 0 ); else R . setStart (g.lines[ 0 ].node, g.lines[ 0 ].offset);
153 if (gi < groups. length - 1 ) R . setEnd (groups[gi + 1 ].lines[ 0 ].node, groups[gi + 1 ].lines[ 0 ].offset); else R . setEnd (block, block.childNodes. length );
154 const f = g.lines[ 0 ]; const x = Math. round (g.availL);
155 return { rich: tidy ( rich (block, R )), lineTop: Math. round ((f.top + scrollY + (f.h - lhB) / 2 ) * 10 ) / 10 , box: { x, y: Math. round (f.top + scrollY + (f.h - lhB) / 2 ), w: Math. round (g.availR) - x, h: Math. round (g.lines. length * lhB) }, lines: g.lines. length };
156 });
157 }
158 }
159 let occluded = false ;
160 if (lineStarts. length ) { // scroll the line into view, ignore floating (fixed) layers, test what is on top
161 const f = lineStarts[ 0 ]; const sy = scrollY; const absTop = f.top + sy; scrollTo ( 0 , Math. max ( 0 , absTop - 300 ));
162 const fixedUp = ( e ) => { for ( let p = e; p; p = p.parentElement) if ( getComputedStyle (p).position === 'fixed' ) return true ; return false ; };
163 const right = Math. min (br.right, f.left + tr.width) - 4 ; // sample the first line at three points: hidden only if all are covered
164 occluded = [f.left + 4 , (f.left + right) / 2 , right]. every (( x ) => { const hit = document. elementsFromPoint (x, absTop - scrollY + f.h / 2 ). find (( e ) => ! fixedUp (e)); return !! hit && hit !== block && ! block. contains (hit); });
165 scrollTo ( 0 , sy); }
166 texts. push ({
167 rich: tidy ( rich (block, null )), segments, occluded, hiddenText,
168 lines: lineStarts. length , linePitch: lineStarts. length > 1 ? Math. round (((lineStarts[lineStarts. length - 1 ].top - lineStarts[ 0 ].top) / (lineStarts. length - 1 )) * 10 ) / 10 : null ,
169 text: block.innerText. replace ( / \s + \n / g , ' \n ' ). trim (). slice ( 0 , 400 ),
170 html: block.innerHTML. replace ( / \s + / g , ' ' ). trim (). slice ( 0 , 600 ),
171 explicitBreaks: block. querySelectorAll ( 'br' ). length ,
172 // the painted WORDS only (inline images excluded): single-line boxes are sized and centred on it
173 glyphBox: (() => { let l = 1e9 , t = 1e9 , r = - 1e9 , b = - 1e9 ; const tw3 = document. createTreeWalker (block, NodeFilter. SHOW_TEXT ); while (tw3. nextNode ()) { const tn = tw3.currentNode; if ( ! tn.textContent. trim () || ! inBlock (tn)) continue ; const rr = document. createRange (); rr. selectNodeContents (tn); for ( const q of rr. getClientRects ()) { if ( ! q.width) continue ; l = Math. min (l, q.left); t = Math. min (t, q.top); r = Math. max (r, q.right); b = Math. max (b, q.bottom); } } return l < 1e9 ? { x: Math. round (l), y: Math. round (t + scrollY), w: Math. round (r - l), h: Math. round (b - t) } : null ; })(),
174 // the first painted glyph line sets lineTop (an inline image on the first line must not)
175 block: R (br), textBox: R (tr), lineTop: lineStarts. length && ! hiddenText ? Math. round ((lineStarts[ 0 ].top + scrollY + (lineStarts[ 0 ].h - lhB) / 2 ) * 10 ) / 10 : lineTop,
176 padding: [bs.paddingTop, bs.paddingRight, bs.paddingBottom, bs.paddingLeft]. join ( ' ' ),
177 font: base.fontFamily, size: base.fontSize, weight: base.fontWeight, style: base.fontStyle, color: base.color,
178 lineHeight: base.lineHeight, letterSpacing: base.letterSpacing, transform: base.textTransform, align: bs.textAlign,
179 decoration: base.textDecorationLine, shadow: base.textShadow,
180 links: [ ...new Set ([ ... block. querySelectorAll ( 'a' )]. map (( a ) => a.href). concat (block. closest ( 'a' ) ? [block. closest ( 'a' ).href] : []))],
181 });
182 }
183 const walk = ( root ) => { for ( const e of root. querySelectorAll ( '*' )) { const s = getComputedStyle (e); if (s.position === 'fixed' && visible (e)) {
184 // report the clickable child (the visible pill), not a full-width wrapper
185 const t = e. matches ( 'a,button' ) ? e : (e. querySelector ( 'a,button' ) || e);
186 const ts = getComputedStyle (t), r = t. getBoundingClientRect ();
187 fixed. push ({ tag: t.tagName, cls: String (t.className || '' ). slice ( 0 , 80 ), box: { x: Math. round (r.x), y: Math. round (r.y), w: Math. round (r.width), h: Math. round (r.height) }, bottomOffset: Math. round (innerHeight - r.bottom), text: (t.innerText || '' ). trim (). slice ( 0 , 120 ), href: t.href || null , background: ts.backgroundColor, radius: ts.borderRadius, padding: ts.padding, font: ts.fontFamily, size: ts.fontSize, weight: ts.fontWeight, color: ts.color });
188 } if (e.shadowRoot) walk (e.shadowRoot); } };
189 walk (document);
190 // every visible link, with its box: build-description turns each one the text and image links do not
191 // already cover into a transparent click area, so no source link is lost (icons, logo, nav, tiles)
192 const anchors = [];
193 for ( const a of document. querySelectorAll ( 'a[href]' )) {
194 const h = a. getAttribute ( 'href' ) || '' ; if ( ! h || h. startsWith ( '#' ) || / ^ javascript:/ i . test (h) || ! visible (a)) continue ;
195 const r = a. getBoundingClientRect (); if (r.width < 4 || r.height < 4 ) continue ;
196 anchors. push ({ href: a.href, text: ((a.innerText || '' ). trim (). replace ( / \s + / g , ' ' ) || a. getAttribute ( 'aria-label' ) || (a. querySelector ( 'img' ) && a. querySelector ( 'img' ).alt) || '' ). slice ( 0 , 60 ), box: { x: Math. round (r.x + scrollX), y: Math. round (r.y + scrollY), w: Math. round (r.width), h: Math. round (r.height) } });
197 }
198 return { anchors, height: Math. max (document.documentElement.scrollHeight, document.body.scrollHeight, Math. round (document.body. getBoundingClientRect ().bottom + scrollY)) /* the full scrollable page, body margins included */ , backgrounds, images, texts, fixed, unsupported };
199 });
200 await page. close ();
201 return data;
202 }
203
204 const main = await measure (width);
205 const wide = await measure (width + 640 );
206 const dx = wide.images. map (( im , i ) => (main.images[i] ? im.box.x - main.images[i].box.x : null )). filter (( v ) => v != null );
207 const centered = dx. length > 0 && dx. every (( v ) => v === dx[ 0 ]);
208 const result = { url, width, measuredAt: new Date (). toISOString (), page: { height: main.height, centeringDx: [ ...new Set (dx)], centered, offsetAtPlusWidth: centered ? dx[ 0 ] : null }, ... main };
209 fs. writeFileSync (out, JSON . stringify (result, null , 1 ));
210 await browser. close ();
211 console. log ( JSON . stringify ({ out, height: main.height, centered: result.page.centered, centeringDx: result.page.centeringDx, backgrounds: main.backgrounds. length , images: main.images. length , texts: main.texts. length , fixed: main.fixed. length }));