Setting the file. One moment.
Wix Build · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 231
function buildSeoData
— line 231
This file
Number 38.172
Position 172 of 174
Type JavaScript
Size 25 KB
Lines 455 lib/ wix-build.js
JavaScript · 455 lines · 25 KB
14 // route through them, so coverage reporting stays complete.
15
16 const { inventoryForVariant } = require ( './stores-inventory.js' );
17 const { STORES_V3_TARGET , CATEGORY_TARGET , CANONICAL_FIELDS } = require ( './wix-target-spec.js' );
18
19 // --- coercion --------------------------------------------------------------
20 // Money must be an OBJECT ({ amount: '20.00' }); a bare number 400s. Decimal STRINGS avoid float
21 // drift on amounts that came out of a CSV as text in the first place.
22 function money ( amount ) {
23 const n = Number (amount);
24 if ( ! Number. isFinite (n)) throw new Error ( `money: "${ amount }" is not a finite number` );
25 return { amount: n. toFixed ( 2 ) };
26 }
27
28 // Wix rejects a slug containing ^!“”$%&'*+,.:;<>=?@\/()[]{}_|` ~#. Vendors mint illegal ones
29 // routinely — Shopify turns a decimal size into an underscore (ph-5_5), which 400s the whole
30 // bulk batch, so this runs on EVERY slug rather than being a vendor quirk.
31 //
32 // CORRECTED 2026-09-27: this used to collapse to `[^a-z0-9-]+` — an ASCII allowlist that also
33 // stripped every non-Latin letter (Hebrew, Chinese, Cyrillic, ...), silently sanitizing an
34 // all-non-Latin slug down to near-empty. Confirmed live: non-Latin (e.g. Hebrew)
35 // product slugs written via direct PATCH read back byte-identical (see
36 // domains/stores/entities/product.json's wordpress-percent-encoded-slug pitfall, which only
37 // documents Wix stripping literal '%', never a non-Latin-letter rejection). Stripping non-Latin
38 // letters here would otherwise silently defeat decodeWpSlug's percent-decode for every non-Latin
39 // source site: decode restores the real non-Latin text, then this function threw it away again.
40 //
41 // REWRITTEN 2026-09-29: a hand
42 // enumerated punctuation BLOCKLIST is what the comment above still describes Wix's actual rule as
43 // -- but successive checks each found a DIFFERENT class of character neither this
44 // blocklist nor its two follow-on patches (C0/C1 control bytes, then Cf zero-width/format chars)
45 // happened to cover, most recently other Unicode symbols/punctuation entirely outside all three
46 // enumerations (™, §, •, an em dash, ½, ...). A blocklist enumeration is structurally incomplete
47 // by construction -- there is always a next uncovered class -- so this is now the other shape of
48 // the same rule, an ALLOWLIST of what to KEEP: Unicode letters (\p{L}, live-verified safe: the non-Latin
49 // slugs above), combining marks (\p{M}, so a decomposed accented letter written as base +
50 // combining accent, e.g. "e" + U+0301, survives as one unit instead of splitting into a hyphen),
51 // and decimal digits (\p{Nd} specifically, not the broader \p{N} -- Unicode "Other Number" also
52 // covers vulgar fractions and Roman numerals like "½"/"Ⅷ", which are exactly the kind of
53 // symbol-shaped character this rewrite exists to stop assuming is a safe slug character just
54 // because it happens to be a "number"). Everything outside that set -- known punctuation, control
55 // bytes, format chars, or a class nobody has hit yet -- becomes a hyphen. An allowlist by category
56 // is exhaustive; a blocklist enumeration cannot be, which is the whole reason this needed a 4th
57 // patch after three were already shipped.
58 const WIX_SLUG_ILLEGAL_CHARS = / [ ^ \p {L} \p {M} \p {Nd}-] / gu ;
59 function toWixSlug ( raw ) {
60 const slug = String (raw == null ? '' : raw)
61 . toLowerCase ()
62 . replace ( / [\s_] + / g , '-' )
63 . replace ( WIX_SLUG_ILLEGAL_CHARS , '-' )
64 . replace ( /- {2,} / g , '-' )
65 . replace ( / ^ - +| - +$ / g , '' );
66 // Non-empty is not enough: \p{M} combining marks (kept above for a decomposed accented
67 // letter, base + combining accent) also cover invisible marks like emoji variation selectors
68 // (U+FE0F etc), so an emoji/symbol-only title -- "heavy black heart" + VS-16, say -- can
69 // sanitize to a slug made of nothing but an invisible character. Require the survivor to carry
70 // at least one actual letter or digit; anything else is the same "no usable slug" case as
71 // sanitizing to empty, and gets the same deterministic-source-id-fallback treatment from the
72 // caller.
73 if ( ! slug || ! / [ \p {L} \p {Nd}] / u . test (slug)) {
74 throw new Error ( `toWixSlug: "${ raw }" sanitizes to an empty slug` );
75 }
76 return slug;
77 }
78
79 function coerceBoolean ( value , fallback ) {
80 if (value === true || value === false ) return value;
81 if (value == null || value === '' ) return fallback;
82 const s = String (value). trim (). toLowerCase ();
83 if ([ 'true' , '1' , 'yes' , 'y' , 'active' , 'published' , 'enabled' , 'visible' ]. includes (s)) return true ;
84 if ([ 'false' , '0' , 'no' , 'n' , 'draft' , 'archived' , 'disabled' , 'hidden' ]. includes (s)) return false ;
85 return fallback;
86 }
87
88 const COERCE = { slug: toWixSlug, money, boolean: coerceBoolean };
89
90 function isBlank ( value ) {
91 return value == null || value === '' || (Array. isArray (value) && value. length === 0 );
92 }
93
94 // Classification is deliberately based on visible text, while the value returned to Wix retains
95 // its source HTML. WooCommerce descriptions regularly contain empty wrappers, , or the
96 // stock "Lorem ipsum" filler; importing any of those is equivalent to importing no copy.
97 function normalizedDescriptionText ( value ) {
98 return String (value == null ? '' : value)
99 . replace ( /<!-- [\s\S] *? -->/ g , ' ' )
100 . replace ( /< [ ^ >] * >/ g , ' ' )
101 . replace ( /&(nbsp | #0 * 160 | #x0 * a0);/ gi , ' ' )
102 . replace ( /&(amp | quot | #0 * 39 | #x0 * 27);/ gi , ' ' )
103 . normalize ( 'NFKC' )
104 . replace ( / \s + / g , ' ' )
105 . trim ();
106 }
107
108 function isPlaceholderDescription ( value ) {
109 return normalizedDescriptionText (value). toLowerCase (). startsWith ( 'lorem ipsum' );
110 }
111
112 // Wix Stores has one product description, whereas WooCommerce supplies a short, above-the-fold
113 // body and a long body. Preserve each non-placeholder source body in source reading order. The
114 // horizontal rule is intentional content structure, not a visual approximation with blank lines.
115 function composeProductDescription ({ description , shortDescription } = {}) {
116 const long = normalizedDescriptionText (description) && ! isPlaceholderDescription (description) ? description : undefined ;
117 const short = normalizedDescriptionText (shortDescription) && ! isPlaceholderDescription (shortDescription) ? shortDescription : undefined ;
118 if (short && long) return `${ short } \n <hr /> \n ${ long }` ;
119 return short || long;
120 }
121
122 // --- structural builders ---------------------------------------------------
123 // Option definitions are derived from the variants' own choices, so a value that never occurs
124 // cannot produce a phantom choice. Order is first-seen, which preserves the source's ordering.
125 function buildOptions ( product ) {
126 const names = product.optionNames || [];
127 return names. map (( name ) => {
128 const seen = new Map ();
129 for ( const variant of product.variants || []) {
130 for ( const choice of variant.choices || []) {
131 if (choice.optionName === name && ! seen. has (choice.choiceName)) {
132 seen. set (choice.choiceName, { choiceType: 'CHOICE_TEXT' , name: choice.choiceName });
133 }
134 }
135 }
136 return { name, optionRenderType: 'TEXT_CHOICES' , choicesSettings: { choices: [ ... seen. values ()] } };
137 });
138 }
139
140 // A variant references its option choices BY NAME; the API returns server-assigned choice ids.
141 // An empty `choices: []` is the correct encoding of "this product has no real options" — it must
142 // produce a single default variant, never a synthetic one-choice option.
143 function buildVariants ( product , { inStock } = {}) {
144 return (product.variants || []). map (( variant ) => {
145 const out = {
146 visible: coerceBoolean (variant.visible, true ),
147 choices: (variant.choices || []). map (( choice ) => ({
148 optionChoiceNames: {
149 optionName: choice.optionName,
150 choiceName: choice.choiceName,
151 renderType: 'TEXT_CHOICES' ,
152 },
153 })),
154 price: buildVariantPrice (variant),
155 };
156 const inventory = inventoryForVariant (variant, { inStock });
157 if (inventory) out.inventoryItem = inventory;
158 if ( ! isBlank (variant.sku)) out.sku = String (variant.sku);
159 // Weight is expected already converted to the site's unit by the adapter; the canonical field
160 // carries a number, not a unit-bearing string.
161 if ( ! isBlank (variant.weight)) out.physicalProperties = { weight: Number (variant.weight) };
162 return out;
163 });
164 }
165
166 // Implements STORES_V3_TARGET.priceResolution.
167 //
168 // Two vendor idioms converge here. Shopify ships (price, compareAtPrice); Woo / Magento /
169 // BigCommerce ship (regular price, sale price) — which the overlays map to
170 // (variant.price, variant.discountedPrice). Wix models `actualPrice` (charged) plus
171 // `compareAtPrice` (strike-through), so a sale price has to be PROMOTED to actualPrice and the
172 // regular price demoted to the strike-through. Getting this backwards silently overcharges every
173 // discounted product, which is why the rule lives in the spec instead of being re-decided per run.
174 //
175 // TRAP (verified): Wix rejects compareAtPrice <= actualPrice. Vendors write 0.00 — and Woo writes
176 // an empty cell — to mean "no discount", so a not-strictly-greater compare-at is DROPPED.
177 function buildVariantPrice ( variant ) {
178 const listPrice = Number (variant.price);
179 const discounted = variant.discountedPrice;
180
181 let actual = listPrice;
182 let compare = variant.compareAtPrice;
183 if ( ! isBlank (discounted) && Number (discounted) > 0 && Number (discounted) < listPrice) {
184 actual = Number (discounted);
185 compare = listPrice;
186 }
187
188 const out = { actualPrice: money (actual) };
189 if ( ! isBlank (compare) && Number (compare) > actual) out.compareAtPrice = money (compare);
190 return out;
191 }
192
193 // External URLs on media.itemsInfo.items[] are the preferred path: Wix ingests them server-side in
194 // the background, avoiding the throttled Media Manager pre-import. The FIRST item becomes the
195 // product's main media, so caller-side ordering matters.
196 // TRAP: reading these back needs `?fields=MEDIA_ITEMS_INFO`; a plain GET returns only media.main
197 // and looks empty even on a successful ingest.
198 //
199 // The url this emits is a REQUEST TO FETCH, not a stored file. Wix returns 200 before it has
200 // fetched anything, and a failed background fetch drops the item and leaves the product with an
201 // EMPTY gallery — silently, and after the old image was already replaced. A caller
202 // that writes this media and trusts the 200 has not finished; use
203 // `patchStoresProductMediaVerified` or run an equivalent read-back sweep.
204 //
205 // `image.url` MUST arrive already resolved to the source's original, maximum-resolution file and
206 // already percent-encoded. Wix copies exactly the one file this URL points at and
207 // generates every derivative from it, with no way to raise the quality afterwards — so a crop
208 // passed here is that product's permanent ceiling. Do not "helpfully" reach back to a raw source
209 // field such as `src` here; the source adapter owns that choice and knows which of the several
210 // URLs the source offered is the biggest.
211 function buildMedia ( product ) {
212 const items = (product.images || [])
213 . filter (( image ) => image && image.fetchable !== false && ! isBlank (image.url))
214 . map (( image ) => ({
215 url: image.url,
216 ... ( isBlank (image.altText) ? {} : { altText: image.altText }),
217 ... ( isBlank (image.displayName) ? {} : { displayName: image.displayName }),
218 }));
219 return items. length ? { itemsInfo: { items } } : null ;
220 }
221
222 // A product that carries `seoEvidence` (an evidence-contract source such as Yoast on
223 // WooCommerce) emits a tag ONLY for a proven per-field override, or for the owner's explicit
224 // `materialize` choice via `seoPlan` (rp-target-wix/lib/product-seo.js planProductSeo). Its
225 // `seoTitle`/`seoDescription` are ignored: on such sources those are rendered template output,
226 // and writing them turns an inherited pattern into a hard per-product override. A proven override
227 // is written even when it equals the product name.
228 //
229 // A product WITHOUT `seoEvidence` is the legacy CSV path (Shopify/Magento/BigCommerce SEO
230 // columns are authored per-product values): unchanged, including the duplicate-of-name skip.
231 function buildSeoData ( product , { seoPlan } = {}) {
232 if (product.seoEvidence !== undefined && product.seoEvidence !== null ) {
233 let tags;
234 if (seoPlan) {
235 if (seoPlan.productId !== undefined && product.sourceId != null && String (seoPlan.productId) !== String (product.sourceId)) {
236 throw new Error ( `buildSeoData: seoPlan for ${ seoPlan . productId } passed for product ${ product . sourceId }` );
237 }
238 tags = seoPlan.tags;
239 } else {
240 tags = [];
241 const ev = product.seoEvidence;
242 if (ev.title && ev.title.state === 'override' && ! isBlank (ev.title.value)) tags. push ({ type: 'title' , children: String (ev.title.value) });
243 if (ev.description && ev.description.state === 'override' && ! isBlank (ev.description.value)) {
244 tags. push ({ type: 'meta' , props: { name: 'description' , content: String (ev.description.value) } });
245 }
246 }
247 return tags. length ? { tags: tags. map (( tag ) => JSON . parse ( JSON . stringify (tag))) } : null ;
248 }
249 const tags = [];
250 if ( ! isBlank (product.seoTitle) && String (product.seoTitle). trim () !== String (product.name || '' ). trim ()) {
251 tags. push ({ type: 'title' , children: String (product.seoTitle) });
252 }
253 if ( ! isBlank (product.seoDescription)) {
254 tags. push ({ type: 'meta' , props: { name: 'description' , content: String (product.seoDescription) } });
255 }
256 return tags. length ? { tags } : null ;
257 }
258
259 // --- product ---------------------------------------------------------------
260 function setPath ( target , path , value ) {
261 const parts = path. split ( '.' );
262 let cursor = target;
263 for ( let i = 0 ; i < parts. length - 1 ; i += 1 ) {
264 cursor[parts[i]] = cursor[parts[i]] || {};
265 cursor = cursor[parts[i]];
266 }
267 cursor[parts[parts. length - 1 ]] = value;
268 }
269
270 // `sourceDeclaredOptionNames` says which options the SOURCE declared, independent of
271 // whether any variant choices could be resolved from them. `choices: []` must keep meaning "no
272 // real options", so a product that declared options yet built none is a LOST-options product,
273 // and it throws unless the caller passes the user's own recorded decision for THIS product. The
274 // decision is bound twice (key and value both name `product.sourceId`), so product A's entry can
275 // never be replayed for B, even by rebuilding the key from B's id.
276 function isValidOptionLossDecision ( product , decision ) {
277 if ( ! decision || typeof decision !== 'object' ) return false ;
278 const { key , entry } = decision;
279 const sourceId = product.sourceId == null ? '' : String (product.sourceId);
280 if ( ! sourceId. trim () || key !== `optionLoss:${ sourceId }` ) return false ;
281 if ( ! entry || typeof entry !== 'object' || entry.source !== 'user' ) return false ;
282 const value = entry.value;
283 if ( ! value || typeof value !== 'object' ) return false ;
284 if (value.accept !== 'default-variant' || value.sourceProductId !== sourceId) return false ;
285 const declared = product.sourceDeclaredOptionNames;
286 const accepted = value.declaredOptionNames;
287 return (
288 Array. isArray (accepted) &&
289 accepted. length === declared. length &&
290 accepted. every (( name , index ) => name === declared[index])
291 );
292 }
293
294 function assertDeclaredOptionsResolved ( product , options , optionLossDecision , modifiers = []) {
295 const declared = product.sourceDeclaredOptionNames;
296 if (declared === undefined || declared === null ) return ;
297 if ( ! Array. isArray (declared)) {
298 throw new Error ( `buildProduct: "${ product . name }" sourceDeclaredOptionNames must be an array of option names` );
299 }
300 // A routed product (one that carries `modifiers`) must account for EVERY declared
301 // option, as exactly one variant option or one connected modifier. Partial loss throws too.
302 if (Array. isArray (product.modifiers)) {
303 const built = [ ... options. map (( o ) => o.name), ... product.modifiers. map (( m ) => m.name)];
304 const missing = declared. filter (( name ) => built. filter (( b ) => b === name). length !== 1 );
305 const extra = built. filter (( name ) => ! declared. includes (name));
306 if ( ! missing. length && ! extra. length && modifiers. length === product.modifiers. length ) return ;
307 throw new Error (
308 `buildProduct: "${ product . name }" (${ product . sourceId }) declared options [${ declared . join ( ', ' ) }] but built options [${ options . map (( o ) => o . name ). join ( ', ' ) }] and modifiers [${ product . modifiers . map (( m ) => m . name ). join ( ', ' ) }]; unaccounted [${ [ ... missing , ... extra ]. join ( ', ' ) }]; record the loss in mapping-gaps.json.` ,
309 );
310 }
311 if ( ! declared. length || options. length ) return ;
312 if ( isValidOptionLossDecision (product, optionLossDecision)) return ;
313 throw new Error (
314 `buildProduct: "${ product . name }" (${ product . sourceId }) declared options [${ declared . join ( ', ' ) }] but resolved to zero; record the loss in mapping-gaps.json (no valid human optionLoss decision for this product).` ,
315 );
316 }
317
318 // Builds the Stores V3 create body from a canonical product record.
319 // Explicit variant stock wins. A caller may supply an approved fallback for missing stock;
320 // there is no implicit availability default. Codegen reports variants without stock evidence.
321 // The product side of each routed modifier is the connected modifier of its written
322 // customization (wix-writers.js connectedModifierFromCustomization), passed in route order. Its
323 // name, mandatory flag and choice list must equal what the route derived from the source.
324 function attachModifiers ( product , connectedModifiers ) {
325 const wanted = product.modifiers;
326 if ( ! Array. isArray (wanted) || ! wanted. length ) {
327 if (connectedModifiers && connectedModifiers. length ) throw new Error ( `buildProduct: "${ product . name }" got connected modifiers but its route has none` );
328 return [];
329 }
330 if ( ! Array. isArray (connectedModifiers) || connectedModifiers. length !== wanted. length ) {
331 throw new Error ( `buildProduct: "${ product . name }" needs one connected modifier per routed modifier (${ wanted . length }); write the customizations first` );
332 }
333 wanted. forEach (( m , i ) => {
334 const c = connectedModifiers[i] || {};
335 const names = (c.choicesSettings?.choices || []). map (( choice ) => choice.name);
336 if ( ! c.id || c.name !== m.customizationName || c.mandatory !== m.mandatory || c.modifierRenderType !== 'TEXT_CHOICES'
337 || names. length !== m.choices. length || names. some (( name , k ) => name !== m.choices[k])) {
338 throw new Error ( `buildProduct: "${ product . name }" connected modifier ${ i } does not match routed modifier "${ m . name }" (customization "${ m . customizationName }")` );
339 }
340 });
341 return connectedModifiers;
342 }
343
344 // Catalog V3 product.name maxLength 80 (live 400 MAX_LENGTH). Cut on whole characters within 80
345 // UTF-16 units, so a name is never split inside a surrogate pair and fits however Wix counts it;
346 // the writer's clamp uses this same function, so the two always agree.
347 const PRODUCT_NAME_MAX = 80 ;
348 function truncateCodePoints ( value , max ) {
349 const s = String (value);
350 if (s. length <= max) return s;
351 let out = '' ;
352 for ( const ch of s) { if (out. length + ch. length > max) break ; out += ch; }
353 return out. trimEnd ();
354 }
355 // A slug that sanitizes to nothing (an emoji- or symbol-only title) never blocks the product: it
356 // becomes the deterministic product-<source id>, or is left to Wix when there is no source id.
357 function productSlugOrFallback ( raw , product , findings ) {
358 try { return toWixSlug (raw); } catch (error) {
359 if ( ! /empty slug/ . test ( String (error && error.message))) throw error;
360 let fallback;
361 try { fallback = isBlank (product.sourceId) ? undefined : `product-${ toWixSlug ( product . sourceId ) }` ; } catch { fallback = undefined ; }
362 findings. push ({ code: 'product-slug-minted' , field: 'product.slug' , sourceId: product.sourceId ?? null , slug: fallback ?? null });
363 return fallback;
364 }
365 }
366 // `findings` (optional array) receives product-name-truncated / product-slug-minted: the full
367 // name stays in the finding and the source archive, so the report can show what was shortened.
368 function buildProduct ( product , { inStock , target = STORES_V3_TARGET , optionLossDecision , seoPlan , connectedModifiers , findings = [] } = {}) {
369 if ( isBlank (product.name)) throw new Error ( 'buildProduct: product.name is required' );
370 const body = { ... target.constants };
371 const composedDescription = composeProductDescription (product);
372
373 // Spec-driven scalars.
374 for ( const [ canonicalPath , fieldSpec ] of Object. entries (target.fields)) {
375 if ( ! fieldSpec.payloadPath) continue ; // structural: handled below
376 const key = canonicalPath. replace ( / ^ product \. / , '' );
377 let value = canonicalPath === 'product.description' ? composedDescription : product[key];
378 const def = CANONICAL_FIELDS [canonicalPath] || {};
379 if ( isBlank (value) && def.default !== undefined ) value = def.default;
380 if ( isBlank (value) && typeof value !== 'boolean' ) continue ;
381 if (fieldSpec.coerce === 'slug' ) { value = productSlugOrFallback (value, product, findings); if (value === undefined ) continue ; }
382 else if (fieldSpec.coerce && COERCE [fieldSpec.coerce]) value = COERCE [fieldSpec.coerce](value);
383 else if (def.kind === 'boolean' ) value = coerceBoolean (value, def.default);
384 if (canonicalPath === 'product.name' && typeof value === 'string' && value. length > PRODUCT_NAME_MAX ) {
385 findings. push ({ code: 'product-name-truncated' , field: 'product.name' , sourceId: product.sourceId ?? null , fullName: value, maxLength: PRODUCT_NAME_MAX });
386 value = truncateCodePoints (value, PRODUCT_NAME_MAX );
387 }
388 // A spec-declared length cap is enforced here rather than truncated: silently shortening a
389 // description is exactly the invisible data loss this pipeline exists to avoid, and the caller
390 // is the one who can decide between truncating, splitting into an info section, or skipping.
391 if (fieldSpec.maxLength && typeof value === 'string' && value. length > fieldSpec.maxLength) {
392 throw new Error (
393 `buildProduct: "${ product . name }" has a ${ value . length }-character ${ canonicalPath } but Wix caps ${ fieldSpec . payloadPath } at ${ fieldSpec . maxLength }. Truncate it, move the overflow into an info section, or skip the field — and record the loss in mapping-gaps.json.` ,
394 );
395 }
396 setPath (body, fieldSpec.payloadPath, fieldSpec.wrap ? fieldSpec. wrap (value) : value);
397 }
398
399 // Structural builders, declared in the spec via `via`.
400 const options = buildOptions (product);
401 const modifiers = attachModifiers (product, connectedModifiers);
402 assertDeclaredOptionsResolved (product, options, optionLossDecision, modifiers);
403 if (options. length ) body.options = options;
404 if (modifiers. length ) body.modifiers = modifiers;
405 const media = buildMedia (product);
406 if (media) body.media = media;
407 const seoData = buildSeoData (product, { seoPlan });
408 if (seoData) body.seoData = seoData;
409 body.variantsInfo = { variants: buildVariants (product, { inStock }) };
410 if ( ! body.variantsInfo.variants. length ) {
411 throw new Error ( `buildProduct: "${ product . name }" produced no variant; every Wix product needs at least one` );
412 }
413 return body;
414 }
415
416 // --- category --------------------------------------------------------------
417 // Categories are created depth-ascending so a parent id is always resolvable. Dedupe against a
418 // live site must key on name + PARENT, never name alone: a real tree repeats a name at different
419 // depths (e.g. "Accessories" under two different parents).
420 function buildCategory ( category , { categoryIdByPath = new Map (), target = CATEGORY_TARGET } = {}) {
421 if ( isBlank (category.name)) throw new Error ( 'buildCategory: category.name is required' );
422 const body = { ... target.constants };
423 for ( const [ canonicalPath , fieldSpec ] of Object. entries (target.fields)) {
424 if ( ! fieldSpec.payloadPath) continue ;
425 const key = canonicalPath. replace ( / ^ category \. / , '' );
426 let value = category[key];
427 const def = CANONICAL_FIELDS [canonicalPath] || {};
428 if ( isBlank (value) && def.default !== undefined ) value = def.default;
429 if ( isBlank (value) && typeof value !== 'boolean' ) continue ;
430 if (def.kind === 'boolean' ) value = coerceBoolean (value, def.default);
431 setPath (body, fieldSpec.payloadPath, value);
432 }
433 const parentId = category.parentPath ? categoryIdByPath. get (category.parentPath) : null ;
434 if (parentId) body.parentCategory = { id: parentId };
435 return body;
436 }
437
438 module . exports = {
439 money,
440 toWixSlug,
441 PRODUCT_NAME_MAX,
442 truncateCodePoints,
443 WIX_SLUG_ILLEGAL_CHARS,
444 coerceBoolean,
445 normalizedDescriptionText,
446 isPlaceholderDescription,
447 composeProductDescription,
448 buildOptions,
449 buildVariants,
450 buildVariantPrice,
451 buildMedia,
452 buildSeoData,
453 buildProduct,
454 buildCategory,
455 };