Setting the file. One moment.
Wix Build · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page Post
function buildCategory
— line 208
This file
Number 44.113
Position 113 of 115
Type JavaScript
Size 11 KB
Lines 237 lib/ wix-build.js
JavaScript · 237 lines · 11 KB
14 // route through them, so coverage reporting stays complete.
15
16 const { STORES_V3_TARGET , CATEGORY_TARGET , CANONICAL_FIELDS } = require ( './wix-target-spec.js' );
17
18 // --- coercion --------------------------------------------------------------
19 // Money must be an OBJECT ({ amount: '20.00' }); a bare number 400s. Decimal STRINGS avoid float
20 // drift on amounts that came out of a CSV as text in the first place.
21 function money ( amount ) {
22 const n = Number (amount);
23 if ( ! Number. isFinite (n)) throw new Error ( `money: "${ amount }" is not a finite number` );
24 return { amount: n. toFixed ( 2 ) };
25 }
26
27 // Wix rejects a slug containing ^!“”$%&'*+,.:;<>=?@\/()[]{}_|` ~#. Vendors mint illegal ones
28 // routinely — Shopify turns a decimal size into an underscore (ph-5_5), which 400s the whole
29 // bulk batch, so this runs on EVERY slug rather than being a vendor quirk.
30 function toWixSlug ( raw ) {
31 const slug = String (raw == null ? '' : raw)
32 . toLowerCase ()
33 . replace ( / [ ^ a-z0-9-] + / g , '-' )
34 . replace ( /- {2,} / g , '-' )
35 . replace ( / ^ - +| - +$ / g , '' );
36 if ( ! slug) throw new Error ( `toWixSlug: "${ raw }" sanitizes to an empty slug` );
37 return slug;
38 }
39
40 function coerceBoolean ( value , fallback ) {
41 if (value === true || value === false ) return value;
42 if (value == null || value === '' ) return fallback;
43 const s = String (value). trim (). toLowerCase ();
44 if ([ 'true' , '1' , 'yes' , 'y' , 'active' , 'published' , 'enabled' , 'visible' ]. includes (s)) return true ;
45 if ([ 'false' , '0' , 'no' , 'n' , 'draft' , 'archived' , 'disabled' , 'hidden' ]. includes (s)) return false ;
46 return fallback;
47 }
48
49 const COERCE = { slug: toWixSlug, money, boolean: coerceBoolean };
50
51 function isBlank ( value ) {
52 return value == null || value === '' || (Array. isArray (value) && value. length === 0 );
53 }
54
55 // --- structural builders ---------------------------------------------------
56 // Option definitions are derived from the variants' own choices, so a value that never occurs
57 // cannot produce a phantom choice. Order is first-seen, which preserves the source's ordering.
58 function buildOptions ( product ) {
59 const names = product.optionNames || [];
60 return names. map (( name ) => {
61 const seen = new Map ();
62 for ( const variant of product.variants || []) {
63 for ( const choice of variant.choices || []) {
64 if (choice.optionName === name && ! seen. has (choice.choiceName)) {
65 seen. set (choice.choiceName, { choiceType: 'CHOICE_TEXT' , name: choice.choiceName });
66 }
67 }
68 }
69 return { name, optionRenderType: 'TEXT_CHOICES' , choicesSettings: { choices: [ ... seen. values ()] } };
70 });
71 }
72
73 // A variant references its option choices BY NAME; the API returns server-assigned choice ids.
74 // An empty `choices: []` is the correct encoding of "this product has no real options" — it must
75 // produce a single default variant, never a synthetic one-choice option.
76 function buildVariants ( product , { inStock = true } = {}) {
77 return (product.variants || []). map (( variant ) => {
78 const out = {
79 visible: coerceBoolean (variant.visible, true ),
80 choices: (variant.choices || []). map (( choice ) => ({
81 optionChoiceNames: {
82 optionName: choice.optionName,
83 choiceName: choice.choiceName,
84 renderType: 'TEXT_CHOICES' ,
85 },
86 })),
87 price: buildVariantPrice (variant),
88 inventoryItem: { inStock },
89 };
90 if ( ! isBlank (variant.sku)) out.sku = String (variant.sku);
91 // Weight is expected already converted to the site's unit by the adapter; the canonical field
92 // carries a number, not a unit-bearing string.
93 if ( ! isBlank (variant.weight)) out.physicalProperties = { weight: Number (variant.weight) };
94 return out;
95 });
96 }
97
98 // Implements STORES_V3_TARGET.priceResolution.
99 //
100 // Two vendor idioms converge here. Shopify ships (price, compareAtPrice); Woo / Magento /
101 // BigCommerce ship (regular price, sale price) — which the overlays map to
102 // (variant.price, variant.discountedPrice). Wix models `actualPrice` (charged) plus
103 // `compareAtPrice` (strike-through), so a sale price has to be PROMOTED to actualPrice and the
104 // regular price demoted to the strike-through. Getting this backwards silently overcharges every
105 // discounted product, which is why the rule lives in the spec instead of being re-decided per run.
106 //
107 // TRAP (verified): Wix rejects compareAtPrice <= actualPrice. Vendors write 0.00 — and Woo writes
108 // an empty cell — to mean "no discount", so a not-strictly-greater compare-at is DROPPED.
109 function buildVariantPrice ( variant ) {
110 const listPrice = Number (variant.price);
111 const discounted = variant.discountedPrice;
112
113 let actual = listPrice;
114 let compare = variant.compareAtPrice;
115 if ( ! isBlank (discounted) && Number (discounted) > 0 && Number (discounted) < listPrice) {
116 actual = Number (discounted);
117 compare = listPrice;
118 }
119
120 const out = { actualPrice: money (actual) };
121 if ( ! isBlank (compare) && Number (compare) > actual) out.compareAtPrice = money (compare);
122 return out;
123 }
124
125 // External URLs on media.itemsInfo.items[] are the preferred path: Wix ingests them server-side in
126 // the background, avoiding the throttled Media Manager pre-import. The FIRST item becomes the
127 // product's main media, so caller-side ordering matters.
128 // TRAP: reading these back needs `?fields=MEDIA_ITEMS_INFO`; a plain GET returns only media.main
129 // and looks empty even on a successful ingest.
130 function buildMedia ( product ) {
131 const items = (product.images || [])
132 . filter (( image ) => image && image.fetchable !== false && ! isBlank (image.url))
133 . map (( image ) => ({ url: image.url, ... ( isBlank (image.altText) ? {} : { altText: image.altText }) }));
134 return items. length ? { itemsInfo: { items } } : null ;
135 }
136
137 // Emit a title tag only when it differs from the product name — a duplicate tag says nothing and
138 // is pure noise in the payload.
139 function buildSeoData ( product ) {
140 const tags = [];
141 if ( ! isBlank (product.seoTitle) && String (product.seoTitle). trim () !== String (product.name || '' ). trim ()) {
142 tags. push ({ type: 'title' , children: String (product.seoTitle) });
143 }
144 if ( ! isBlank (product.seoDescription)) {
145 tags. push ({ type: 'meta' , props: { name: 'description' , content: String (product.seoDescription) } });
146 }
147 return tags. length ? { tags } : null ;
148 }
149
150 // --- product ---------------------------------------------------------------
151 function setPath ( target , path , value ) {
152 const parts = path. split ( '.' );
153 let cursor = target;
154 for ( let i = 0 ; i < parts. length - 1 ; i += 1 ) {
155 cursor[parts[i]] = cursor[parts[i]] || {};
156 cursor = cursor[parts[i]];
157 }
158 cursor[parts[parts. length - 1 ]] = value;
159 }
160
161 // Builds the Stores V3 create body from a canonical product record.
162 // `options.inStock` exists because availability is frequently the ONLY importable inventory signal
163 // (most vendor exports carry no quantity column); sending quantity 0 would mark a whole catalog
164 // out of stock, so the caller states the intent explicitly.
165 function buildProduct ( product , { inStock = true , target = STORES_V3_TARGET } = {}) {
166 if ( isBlank (product.name)) throw new Error ( 'buildProduct: product.name is required' );
167 const body = { ... target.constants };
168
169 // Spec-driven scalars.
170 for ( const [ canonicalPath , fieldSpec ] of Object. entries (target.fields)) {
171 if ( ! fieldSpec.payloadPath) continue ; // structural: handled below
172 const key = canonicalPath. replace ( / ^ product \. / , '' );
173 let value = product[key];
174 const def = CANONICAL_FIELDS [canonicalPath] || {};
175 if ( isBlank (value) && def.default !== undefined ) value = def.default;
176 if ( isBlank (value) && typeof value !== 'boolean' ) continue ;
177 if (fieldSpec.coerce && COERCE [fieldSpec.coerce]) value = COERCE [fieldSpec.coerce](value);
178 else if (def.kind === 'boolean' ) value = coerceBoolean (value, def.default);
179 // A spec-declared length cap is enforced here rather than truncated: silently shortening a
180 // description is exactly the invisible data loss this pipeline exists to avoid, and the caller
181 // is the one who can decide between truncating, splitting into an info section, or skipping.
182 if (fieldSpec.maxLength && typeof value === 'string' && value. length > fieldSpec.maxLength) {
183 throw new Error (
184 `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.` ,
185 );
186 }
187 setPath (body, fieldSpec.payloadPath, fieldSpec.wrap ? fieldSpec. wrap (value) : value);
188 }
189
190 // Structural builders, declared in the spec via `via`.
191 const options = buildOptions (product);
192 if (options. length ) body.options = options;
193 const media = buildMedia (product);
194 if (media) body.media = media;
195 const seoData = buildSeoData (product);
196 if (seoData) body.seoData = seoData;
197 body.variantsInfo = { variants: buildVariants (product, { inStock }) };
198 if ( ! body.variantsInfo.variants. length ) {
199 throw new Error ( `buildProduct: "${ product . name }" produced no variant; every Wix product needs at least one` );
200 }
201 return body;
202 }
203
204 // --- category --------------------------------------------------------------
205 // Categories are created depth-ascending so a parent id is always resolvable. Dedupe against a
206 // live site must key on name + PARENT, never name alone: a real tree repeats a name at different
207 // depths (e.g. "Shampoo & Conditioner" under two different parents).
208 function buildCategory ( category , { categoryIdByPath = new Map (), target = CATEGORY_TARGET } = {}) {
209 if ( isBlank (category.name)) throw new Error ( 'buildCategory: category.name is required' );
210 const body = { ... target.constants };
211 for ( const [ canonicalPath , fieldSpec ] of Object. entries (target.fields)) {
212 if ( ! fieldSpec.payloadPath) continue ;
213 const key = canonicalPath. replace ( / ^ category \. / , '' );
214 let value = category[key];
215 const def = CANONICAL_FIELDS [canonicalPath] || {};
216 if ( isBlank (value) && def.default !== undefined ) value = def.default;
217 if ( isBlank (value) && typeof value !== 'boolean' ) continue ;
218 if (def.kind === 'boolean' ) value = coerceBoolean (value, def.default);
219 setPath (body, fieldSpec.payloadPath, value);
220 }
221 const parentId = category.parentPath ? categoryIdByPath. get (category.parentPath) : null ;
222 if (parentId) body.parentCategory = { id: parentId };
223 return body;
224 }
225
226 module . exports = {
227 money,
228 toWixSlug,
229 coerceBoolean,
230 buildOptions,
231 buildVariants,
232 buildVariantPrice,
233 buildMedia,
234 buildSeoData,
235 buildProduct,
236 buildCategory,
237 };