Setting the file. One moment.
Source Regional · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page — line 95
This file
Number 45.151
Position 151 of 182
Type JavaScript
Size 17 KB
Lines 343 lib/ source-regional.js
JavaScript · 343 lines · 17 KB
14 // Comparison is exact everywhere (plan, read-back, gate); there is no alias canonicalisation.
15 // Runtimes disagree on legacy time zone names and Wix's preferred spelling is not verified, so a
16 // legacy alias that differs from the site's name is a difference, never silently equal.
17
18 const crypto = require ( 'node:crypto' );
19 const fs = require ( 'node:fs/promises' );
20 const path = require ( 'node:path' );
21
22 const { ADAPTER_RAW_DIRS } = require ( './source-coverage.js' );
23
24 const SCHEMA_VERSION = 1 ;
25 const SOURCE_REGIONAL_FILE = 'discovery/source-locale.json' ;
26 const ADAPTER_FILE_NAME = 'source-locale.json' ;
27 const STATES = Object. freeze ([ 'known' , 'unknown' , 'supplied' ]);
28 const UNKNOWN_REASONS = Object. freeze ([ 'auth-gated' , 'not-exposed' , 'unreadable' , 'ambiguous' ]);
29 const SCALAR_FIELDS = Object. freeze ([ 'paymentCurrency' , 'timeZone' , 'language' , 'locale' ]);
30 const FIELDS = SCALAR_FIELDS ;
31 // Fields a gate or a setup row is about; the rest are context.
32 const SETTING_FIELDS = Object. freeze ([ 'currency' , 'timeZone' , 'language' , 'locale' ]);
33 const ADAPTERS = Object. freeze ([ 'shopify' , 'wordpress' , 'csv' , 'quick-shopify' , 'quick-woocommerce' , 'sfcc' ]);
34 const TIME_ZONE_MAX = 30 ; // Update Business Region: timeZone maxLength 30.
35
36 class SourceRegionalError extends Error {
37 constructor ( code , message , errors = []) {
38 super (message);
39 this .name = 'SourceRegionalError' ;
40 this .code = code;
41 this .errors = errors;
42 }
43 }
44
45 let currencySet = null ;
46 function knownCurrency ( code ) {
47 if ( typeof code !== 'string' || ! / ^ [A-Z] {3}$ / . test (code)) return false ;
48 if ( ! currencySet) currencySet = new Set (Intl. supportedValuesOf ( 'currency' ));
49 return currencySet. has (code);
50 }
51
52 function knownTimeZone ( name ) {
53 if ( typeof name !== 'string' || name. length === 0 || name. length > TIME_ZONE_MAX ) return false ;
54 // Wix stores this string verbatim. Require an IANA-shaped, case-preserving name before asking
55 // Intl whether the runtime knows it: offsets and lower-case lookalikes must never reach Wix.
56 if (name !== 'UTC' && ! / ^ [A-Z][A-Za-z_] * ( \/ [A-Z][A-Za-z0-9_+-] * ) +$ / . test (name)) return false ;
57 try { new Intl. DateTimeFormat ( 'en' , { timeZone: name }); return true ; } catch { return false ; }
58 }
59
60 const knownLanguage = ( code ) => typeof code === 'string' && / ^ [a-z] {2}$ / . test (code);
61 const knownCountry = ( code ) => typeof code === 'string' && / ^ [A-Z] {2}$ / . test (code);
62 const knownLocaleTag = ( tag ) => typeof tag === 'string' && / ^ [a-z] {2} - [A-Z] {2}$ / . test (tag);
63
64 // --- comparison (A.3): the only definition of "same" -----------------------------------------
65
66 const upper = ( v ) => ( typeof v === 'string' ? v. toUpperCase () : v);
67 const lower = ( v ) => ( typeof v === 'string' ? v. toLowerCase () : v);
68
69 function parseLocale ( value ) {
70 if ( ! value) return null ;
71 if ( typeof value === 'object' ) {
72 return { languageCode: lower (value.languageCode), country: upper (value.country) };
73 }
74 const match = / ^ ( [A-Za-z] {2} ) [-_] ( [A-Za-z] {2} ) $ / . exec ( String (value));
75 return match ? { languageCode: lower (match[ 1 ]), country: upper (match[ 2 ]) } : null ;
76 }
77
78 function sameValue ( field , a , b ) {
79 if (a === undefined || a === null || b === undefined || b === null ) return false ;
80 switch (field) {
81 case 'currency' : return upper (a) === upper (b);
82 case 'timeZone' : return a === b;
83 case 'language' : return lower (a) === lower (b);
84 case 'locale' : {
85 const x = parseLocale (a); const y = parseLocale (b);
86 return Boolean (x && y && x.languageCode === y.languageCode && x.country === y.country);
87 }
88 default : return a === b;
89 }
90 }
91
92 // --- field constructors ------------------------------------------------------------------------
93
94 const knownField = ( value , evidence , extra = {}) => ({ state: 'known' , value, evidence, ... extra });
95 const suppliedField = ( value , evidence , extra = {}) => ({ state: 'supplied' , value, evidence, suppliedBy: 'intake' , ... extra });
96 const unknownField = ( reason , evidence = undefined ) => ({ state: 'unknown' , reason, ... (evidence ? { evidence } : {}) });
97
98 function validValue ( field , value ) {
99 switch (field) {
100 case 'paymentCurrency' : return knownCurrency (value);
101 case 'timeZone' : return knownTimeZone (value);
102 case 'language' : return knownLanguage (value);
103 case 'locale' : return knownLocaleTag (value);
104 case 'country' : return knownCountry (value); // adapter input used to construct locale
105 default : return false ;
106 }
107 }
108
109 const validInference = ( value ) => Boolean (value && typeof value === 'object' && ! Array. isArray (value) && typeof value.basis === 'string' );
110 // An unknown time zone the adapter already recorded as "keep the site's zone, confirm at cutover"
111 // (no named zone at the source and none inferable). Setup does not ask for it.
112 const keepsSiteTimeZone = ( file ) => Boolean (file && file.timeZone && file.timeZone.state === 'unknown'
113 && validInference (file.timeZone.inference) && file.timeZone.inference.basis === 'unresolved' );
114
115 // An adapter hands over `{ value, evidence }` (or `{ reason }` / nothing); this decides what is
116 // written. Invalid values are `unknown` / `unreadable`, absent ones `unknown` / `not-exposed`.
117 function normalizeField ( field , input ) {
118 if (input === undefined || input === null ) return unknownField ( 'not-exposed' );
119 if (input.state === 'unknown' || (input.value === undefined && input.reason)) {
120 const row = unknownField ( UNKNOWN_REASONS . includes (input.reason) ? input.reason : 'not-exposed' , input.evidence);
121 return validInference (input.inference) ? { ... row, inference: input.inference } : row;
122 }
123 if (input.value === undefined || input.value === null || input.value === '' ) return unknownField ( 'not-exposed' , input.evidence);
124 if ( ! validValue (field, input.value)) return unknownField ( 'unreadable' , input.evidence);
125 // A value discovery inferred (e.g. a named zone for a fixed offset) says so: `suppliedBy` names
126 // the inference and `inference` carries its basis, which setup turns into a cutover task.
127 const extra = {};
128 if (input.state === 'supplied' && typeof input.suppliedBy === 'string' && input.suppliedBy) extra.suppliedBy = input.suppliedBy;
129 if ( validInference (input.inference)) extra.inference = input.inference;
130 const evidence = typeof input.evidence === 'string' && input.evidence ? input.evidence : 'adapter' ;
131 return input.state === 'supplied'
132 ? suppliedField (input.value, evidence, extra)
133 : knownField (input.value, evidence, extra);
134 }
135
136 // What every adapter calls. `adapter` is one of ADAPTERS; every other key is optional.
137 function buildSourceRegional ({ adapter , generatedAt , ... inputs } = {}) {
138 if ( ! ADAPTERS . includes (adapter)) throw new SourceRegionalError ( 'source_regional_invalid' , `unknown adapter ${ adapter }` );
139 const file = { schemaVersion: SCHEMA_VERSION , adapter };
140 file.paymentCurrency = normalizeField ( 'paymentCurrency' , inputs.paymentCurrency ?? inputs.currency);
141 file.timeZone = normalizeField ( 'timeZone' , inputs.timeZone);
142 const languageInput = inputs.language ?? inputs.primaryLanguage;
143 file.language = normalizeField ( 'language' , languageInput);
144 let localeInput = inputs.locale;
145 if ( ! localeInput && knownLocaleTag (languageInput?.locale) && languageInput.locale. slice ( 0 , 2 ) === languageInput.value) {
146 localeInput = { state: languageInput.state, value: languageInput.locale, evidence: languageInput.evidence };
147 }
148 if ( ! localeInput) {
149 const country = normalizeField ( 'country' , inputs.country);
150 if ( isSet (file.language) && isSet (country)) {
151 localeInput = {
152 state: file.language.state === 'supplied' || country.state === 'supplied' ? 'supplied' : 'known' ,
153 value: `${ file . language . value }-${ country . value }` ,
154 evidence: [file.language.evidence, country.evidence]. filter (Boolean). join ( '; ' ),
155 };
156 } else {
157 localeInput = { state: 'unknown' , reason: ! isSet (file.language) ? file.language.reason : country.reason, evidence: country.evidence || file.language.evidence };
158 }
159 }
160 file.locale = normalizeField ( 'locale' , localeInput);
161 if (generatedAt) file.generatedAt = generatedAt;
162 return file;
163 }
164
165 // --- validation of a written file ---------------------------------------------------------------
166
167 function validateFieldShape ( field , row , errors ) {
168 if ( ! row || typeof row !== 'object' || Array. isArray (row)) { errors. push ( `${ field }: must be an object` ); return ; }
169 if ( ! STATES . includes (row.state)) { errors. push ( `${ field }: state must be one of ${ STATES . join ( ', ' ) }` ); return ; }
170 if (row.state === 'unknown' ) {
171 if ( ! UNKNOWN_REASONS . includes (row.reason)) errors. push ( `${ field }: unknown requires reason in ${ UNKNOWN_REASONS . join ( ', ' ) }` );
172 if (Object. hasOwn (row, 'value' )) errors. push ( `${ field }: unknown must not carry a value` );
173 return ;
174 }
175 if ( ! validValue (field, row.value)) errors. push ( `${ field }: value is not valid` );
176 if ( typeof row.evidence !== 'string' || ! row.evidence) errors. push ( `${ field }: evidence is required` );
177 if (row.state === 'supplied' && ( typeof row.suppliedBy !== 'string' || ! row.suppliedBy)) errors. push ( `${ field }: supplied requires suppliedBy` );
178 }
179
180 function validateSourceRegional ( file ) {
181 const errors = [];
182 if ( ! file || typeof file !== 'object' || Array. isArray (file)) return { ok: false , errors: [ 'file must be an object' ] };
183 if (file.schemaVersion !== SCHEMA_VERSION ) errors. push ( `schemaVersion must be ${ SCHEMA_VERSION }` );
184 if ( ! ADAPTERS . includes (file.adapter)) errors. push ( `adapter must be one of ${ ADAPTERS . join ( ', ' ) }` );
185 for ( const field of FIELDS ) validateFieldShape (field, file[field], errors);
186 const allowed = new Set ([ 'schemaVersion' , 'adapter' , ... FIELDS , 'generatedAt' ]);
187 for ( const key of Object. keys (file)) if ( ! allowed. has (key)) errors. push ( `${ key }: is not part of the source locale record` );
188 return { ok: errors. length === 0 , errors };
189 }
190
191 // --- what a consumer reads -----------------------------------------------------------------------
192
193 const isSet = ( row ) => Boolean (row && (row.state === 'known' || row.state === 'supplied' ));
194
195 // The comparable value of one setting, or null when the source does not give one.
196 // `locale` is derived: the tag when it carries a region, else language + country; null when neither.
197 function settingValue ( file , setting ) {
198 if ( ! file) return null ;
199 switch (setting) {
200 case 'currency' : return isSet (file.paymentCurrency) ? file.paymentCurrency.value : null ;
201 case 'timeZone' : return isSet (file.timeZone) ? file.timeZone.value : null ;
202 case 'language' : return isSet (file.language) ? file.language.value : null ;
203 case 'locale' : return isSet (file.locale) ? file.locale.value : null ;
204 default : return null ;
205 }
206 }
207
208 // Why a setting has no value, for the unknown rows the receipt and the report carry.
209 function unknownReason ( file , setting ) {
210 const row = setting === 'currency' ? file.paymentCurrency : file[setting];
211 return row && row.state === 'unknown' ? row.reason : null ;
212 }
213
214 // --- files ---------------------------------------------------------------------------------------
215
216 async function writeJsonAtomic ( file , value ) {
217 await fs. mkdir (path. dirname (file), { recursive: true });
218 const tmp = `${ file }.${ process . pid }.${ Date . now () }.tmp` ;
219 await fs. writeFile (tmp, `${ JSON . stringify ( value , null , 2 ) } \n ` );
220 await fs. rename (tmp, file);
221 }
222
223 async function readJsonIfExists ( file ) {
224 try { return JSON . parse ( await fs. readFile (file, 'utf8' )); } catch (error) {
225 if (error.code === 'ENOENT' ) return undefined ;
226 if (error instanceof SyntaxError ) return null ;
227 throw error;
228 }
229 }
230
231 function assertValid ( file , label ) {
232 const result = validateSourceRegional (file);
233 if ( ! result.ok) throw new SourceRegionalError ( 'source_regional_invalid' , `${ label } is invalid: ${ result . errors . join ( '; ' ) }` , result.errors);
234 }
235
236 // A person-supplied value outlives a re-capture: when the fresh capture of a field is unknown and
237 // the file already on disk holds that field as `supplied`, the supplied row is kept. Anything the
238 // source now exposes replaces it.
239 async function keepSupplied ( target , file ) {
240 const prior = await readJsonIfExists (target);
241 if ( ! prior || typeof prior !== 'object' ) return file;
242 const merged = { ... file };
243 for ( const field of FIELDS ) {
244 if (file[field] && file[field].state === 'unknown' && prior[field] && prior[field].state === 'supplied' && validValue (field, prior[field].value)) merged[field] = prior[field];
245 }
246 return merged;
247 }
248
249 // Full adapters write their raw capture here; promotion reads it from here.
250 async function writeAdapterSourceRegional ( projectDir , adapter , file ) {
251 if ( ! Object. hasOwn ( ADAPTER_RAW_DIRS , adapter)) throw new SourceRegionalError ( 'source_regional_invalid' , `adapter ${ adapter } has no raw discovery directory` );
252 assertValid (file, 'source regional file' );
253 const target = path. join (projectDir, ADAPTER_RAW_DIRS [adapter], ADAPTER_FILE_NAME );
254 await writeJsonAtomic (target, await keepSupplied (target, file));
255 return target;
256 }
257
258 // Quick adapters and the SFCC public technique have no promote step; they write the canonical file.
259 async function writeSourceRegional ( projectDir , file ) {
260 assertValid (file, 'source regional file' );
261 const target = path. join (projectDir, SOURCE_REGIONAL_FILE );
262 await writeJsonAtomic (target, await keepSupplied (target, file));
263 return target;
264 }
265
266 async function promoteSourceRegional ( projectDir , { adapter }) {
267 if ( ! Object. hasOwn ( ADAPTER_RAW_DIRS , adapter)) throw new SourceRegionalError ( 'source_regional_invalid' , `adapter ${ adapter } cannot promote a regional file` );
268 const adapterFile = path. join (projectDir, ADAPTER_RAW_DIRS [adapter], ADAPTER_FILE_NAME );
269 const raw = await readJsonIfExists (adapterFile);
270 if (raw === undefined ) {
271 throw new SourceRegionalError ( 'source_regional_missing' , `${ path . relative ( projectDir , adapterFile ) } is missing; the ${ adapter } adapter must write it (unknown values are explicit) before discovery is promoted` );
272 }
273 if (raw === null ) throw new SourceRegionalError ( 'source_regional_invalid' , `${ path . relative ( projectDir , adapterFile ) } is not valid JSON` );
274 assertValid (raw, path. relative (projectDir, adapterFile));
275 if (raw.adapter !== adapter) throw new SourceRegionalError ( 'source_regional_invalid' , `regional file names adapter ${ raw . adapter }, not ${ adapter }` );
276 await writeJsonAtomic (path. join (projectDir, SOURCE_REGIONAL_FILE ), raw);
277 return { path: path. join (projectDir, SOURCE_REGIONAL_FILE ) };
278 }
279
280 // null when the run has no regional file; throws when one exists and is unusable.
281 async function readSourceRegional ( projectDir ) {
282 const raw = await readJsonIfExists (path. join (projectDir, SOURCE_REGIONAL_FILE ));
283 if (raw === undefined ) return null ;
284 if (raw === null ) throw new SourceRegionalError ( 'source_regional_invalid' , `${ SOURCE_REGIONAL_FILE } is not valid JSON` );
285 assertValid (raw, SOURCE_REGIONAL_FILE );
286 return raw;
287 }
288
289 // Synchronous read for file-only checks (the task dependency check): null when absent; throws when unusable.
290 function readSourceRegionalSync ( projectDir ) {
291 let text;
292 try { text = require ( 'node:fs' ). readFileSync (path. join (projectDir, SOURCE_REGIONAL_FILE ), 'utf8' ); } catch (error) {
293 if (error.code === 'ENOENT' ) return null ;
294 throw error;
295 }
296 let raw;
297 try { raw = JSON . parse (text); } catch { throw new SourceRegionalError ( 'source_regional_invalid' , `${ SOURCE_REGIONAL_FILE } is not valid JSON` ); }
298 assertValid (raw, SOURCE_REGIONAL_FILE );
299 return raw;
300 }
301
302 async function sourceRegionalDigest ( projectDir ) {
303 try {
304 return crypto. createHash ( 'sha256' ). update ( await fs. readFile (path. join (projectDir, SOURCE_REGIONAL_FILE ))). digest ( 'hex' );
305 } catch (error) {
306 if (error.code === 'ENOENT' ) return null ;
307 throw error;
308 }
309 }
310
311 module . exports = {
312 SCHEMA_VERSION,
313 SOURCE_REGIONAL_FILE,
314 ADAPTER_FILE_NAME,
315 ADAPTERS,
316 FIELDS,
317 SETTING_FIELDS,
318 STATES,
319 UNKNOWN_REASONS,
320 SourceRegionalError,
321 knownCurrency,
322 knownTimeZone,
323 knownLanguage,
324 knownCountry,
325 knownLocaleTag,
326 parseLocale,
327 sameValue,
328 knownField,
329 suppliedField,
330 unknownField,
331 buildSourceRegional,
332 validateSourceRegional,
333 settingValue,
334 unknownReason,
335 isSet,
336 keepsSiteTimeZone,
337 writeAdapterSourceRegional,
338 writeSourceRegional,
339 promoteSourceRegional,
340 readSourceRegional,
341 readSourceRegionalSync,
342 sourceRegionalDigest,
343 };