Setting the file. One moment. Website Handoff · Wix Replatform · wix/skills · Skills Docs109
function normalizeScopeValue
— line 109
This file
- Number
- 47.32
- Position
- 32 of 34
- Type
- JavaScript
- Size
- 28 KB
- Lines
- 747
lib/website-handoff.js
JavaScript·747 lines·28 KB
getDecisionValue
,
isExplicitUserOneClick
}
=
require
(
'./orchestration-decisions.js'
);
8const { hashArtifact } = require('./artifact-freshness.js');
9
10const SCHEMA_VERSION = 2;
11const FRESHNESS_SCHEMA_VERSION = 1;
12const HANDOFF_ARTIFACTS = {
13 decisions: 'orchestration/decisions.json',
14 sourceSchema: 'source-schema.json',
15 mappingPlan: 'mapping/mapping-plan.json',
16 mappingGaps: 'mapping/review/mapping-gaps.json',
17 setupPlan: 'setup/setup-plan.json',
18 setupRequirements: 'setup/setup-requirements.json',
19 setupBlockers: 'setup/setup-blockers.json',
20 wixEnv: 'config/wix.env',
21 frontendConfig: 'frontend/wix.config.json',
22 completionReport: 'execution/completion-report.json',
23};
24
25async function pathExists(filePath) {
26 try {
27 await fs.access(filePath);
28 return true;
29 } catch {
30 return false;
31 }
32}
33
34async function readJsonIfExists(filePath) {
35 try {
36 return JSON.parse(await fs.readFile(filePath, 'utf8'));
37 } catch (error) {
38 if (error && error.code === 'ENOENT') {
39 return null;
40 }
41 throw error;
42 }
43}
44
45async function readEnvIfExists(filePath) {
46 try {
47 return await readEnvFile(filePath);
48 } catch (error) {
49 if (error && error.code === 'ENOENT') {
50 return {};
51 }
52 throw error;
53 }
54}
55
56function rel(projectDir, targetPath) {
57 return path.relative(projectDir, targetPath).replace(/\\/g, '/');
58}
59
60function inferMigrationProjectRoot(projectDir) {
61 const normalized = path.resolve(projectDir);
62 const parent = path.dirname(normalized);
63 if (path.basename(parent) === 'migrations') {
64 return `migrations/${path.basename(normalized)}`;
65 }
66 return path.basename(normalized);
67}
68
69function ensureArray(value) {
70 if (Array.isArray(value)) return value;
71 return [];
72}
73
74function firstNonEmptyString(...values) {
75 for (const value of values) {
76 if (typeof value === 'string' && value.trim()) {
77 return value.trim();
78 }
79 }
80 return null;
81}
82
83function stableJson(value) {
84 return JSON.stringify(value, null, 2);
85}
86
87function handoffFingerprint(inputFreshness) {
88 return `sha256:${crypto.createHash('sha256').update(JSON.stringify(inputFreshness)).digest('hex')}`;
89}
90
91function uniqueBy(items, keyFn) {
92 const out = [];
93 const seen = new Set();
94 for (const item of items) {
95 const key = keyFn(item);
96 if (!key || seen.has(key)) continue;
97 seen.add(key);
98 out.push(item);
99 }
100 return out;
101}
102
103function normalizeSiteStrategy(value) {
104 if (value === 'new_site' || value === 'new') return 'new';
105 if (value === 'existing_site' || value === 'existing') return 'existing';
106 return value || null;
107}
108
109function normalizeScopeValue(value) {
110 if (typeof value === 'string' && value.trim()) {
111 return { scope: value.trim(), explicitUrls: [] };
112 }
113 if (value && typeof value === 'object') {
114 return {
115 scope: firstNonEmptyString(value.scope, value.selectedScope),
116 explicitUrls: ensureArray(value.explicitUrls).filter((item) => typeof item === 'string' && item.trim()),
117 };
118 }
119 return { scope: null, explicitUrls: [] };
120}
121
122function chooseAutoScope(scopeSuggestions) {
123 const items = ensureArray(scopeSuggestions);
124 const available = new Set(
125 items
126 .map((item) => (item && typeof item.scope === 'string' ? item.scope.trim() : ''))
127 .filter(Boolean),
128 );
129 for (const scope of ['full', 'ecommerce', 'blog', 'home']) {
130 if (available.has(scope)) {
131 return scope;
132 }
133 }
134 return 'home';
135}
136
137function readEntityMappings(mappingPlan) {
138 if (!mappingPlan || typeof mappingPlan !== 'object') return [];
139 if (Array.isArray(mappingPlan.entityMappings)) return mappingPlan.entityMappings;
140 if (Array.isArray(mappingPlan.mappings)) return mappingPlan.mappings;
141 if (mappingPlan.entities && typeof mappingPlan.entities === 'object') {
142 return Object.entries(mappingPlan.entities).map(([sourceEntity, value]) => ({
143 sourceEntity,
144 ...(value || {}),
145 }));
146 }
147 return [];
148}
149
150function readSetupRequirements(setupRequirements) {
151 if (Array.isArray(setupRequirements)) return setupRequirements;
152 if (!setupRequirements || typeof setupRequirements !== 'object') return [];
153 if (Array.isArray(setupRequirements.requirements)) return setupRequirements.requirements;
154 if (Array.isArray(setupRequirements.items)) return setupRequirements.items;
155 return [];
156}
157
158function readSetupBlockers(setupBlockers) {
159 if (Array.isArray(setupBlockers)) return setupBlockers;
160 if (!setupBlockers || typeof setupBlockers !== 'object') return [];
161 if (Array.isArray(setupBlockers.blockers)) return setupBlockers.blockers;
162 if (Array.isArray(setupBlockers.items)) return setupBlockers.items;
163 return [];
164}
165
166function readMappingGaps(mappingPlan, mappingGaps) {
167 if (Array.isArray(mappingGaps)) return mappingGaps;
168 if (mappingGaps && Array.isArray(mappingGaps.gaps)) return mappingGaps.gaps;
169 if (mappingPlan && Array.isArray(mappingPlan.faithfulnessLedger)) return mappingPlan.faithfulnessLedger;
170 return [];
171}
172
173function normalizeAppRequirement(item) {
174 if (!item || typeof item !== 'object') return null;
175 const appName = firstNonEmptyString(item.appName, item.name, item.app, item.displayName);
176 if (!appName) return null;
177 return {
178 appName,
179 appDefId: firstNonEmptyString(item.appDefId, item.appId, item.id),
180 purpose: firstNonEmptyString(item.purpose, item.reason, item.description),
181 requiredByEntities: ensureArray(item.requiredByEntities || item.requiredBy || item.entities),
182 status: firstNonEmptyString(item.status, item.requirementStatus),
183 automation: firstNonEmptyString(item.automation, item.automationMode),
184 verificationSource: firstNonEmptyString(item.verificationSource, item.source),
185 };
186}
187
188function normalizeCollectionRequirement(item) {
189 if (!item || typeof item !== 'object') return null;
190 const collectionName = firstNonEmptyString(item.collectionName, item.name, item.collectionId);
191 if (!collectionName) return null;
192 return {
193 collectionName,
194 collectionId: firstNonEmptyString(item.collectionId, item.dataCollectionId),
195 collectionPurpose: firstNonEmptyString(item.collectionPurpose, item.purpose, item.description),
196 requiredByEntities: ensureArray(item.requiredByEntities || item.requiredBy || item.entities),
197 fields: ensureArray(item.fields),
198 references: ensureArray(item.references),
199 automation: firstNonEmptyString(item.automation, item.automationMode),
200 verificationSource: firstNonEmptyString(item.verificationSource, item.source),
201 };
202}
203
204function routeKindForEntity(mapping) {
205 const explicit = firstNonEmptyString(mapping.routeKind, mapping.templateKind);
206 if (explicit) return explicit;
207 if (mapping.targetStrategy === 'static_page') return 'static';
208 if (mapping.targetDomain === 'cms' || mapping.targetDomain === 'stores' || mapping.targetDomain === 'blog') return 'dynamic';
209 return 'dynamic';
210}
211
212function normalizeBoolean(value) {
213 return typeof value === 'boolean' ? value : null;
214}
215
216function normalizeRouteIntent(entityMappings) {
217 const staticRoutes = [];
218 const dynamicRoutes = [];
219 const redirectIntents = [];
220 const slugPolicies = [];
221 const nativeDataSources = [];
222
223 for (const mapping of entityMappings) {
224 const urlPolicy = mapping && typeof mapping.urlPolicy === 'object'
225 ? mapping.urlPolicy
226 : (mapping && typeof mapping.urlPreservation === 'object' ? mapping.urlPreservation : null);
227 if (!urlPolicy || urlPolicy.public !== true) {
228 continue;
229 }
230
231 const route = {
232 sourceEntity: firstNonEmptyString(mapping.sourceEntity, mapping.entityName),
233 sourceSemantics: firstNonEmptyString(mapping.sourceSemantics),
234 targetRef: firstNonEmptyString(mapping.targetRef),
235 targetDomain: firstNonEmptyString(mapping.targetDomain),
236 targetEntity: firstNonEmptyString(mapping.targetEntity),
237 targetClassification: firstNonEmptyString(mapping.targetClassification),
238 sourceBasePath: firstNonEmptyString(urlPolicy.sourceBasePath),
239 sourceSlugField: firstNonEmptyString(urlPolicy.sourceSlugField),
240 sourceUrlField: firstNonEmptyString(urlPolicy.sourceUrlField),
241 targetBasePath: firstNonEmptyString(urlPolicy.targetBasePath),
242 targetSlugField: firstNonEmptyString(urlPolicy.targetSlugField),
243 preserveBasePath: normalizeBoolean(urlPolicy.preserveBasePath),
244 preserveSlug: normalizeBoolean(urlPolicy.preserveSlug),
245 redirectMode: firstNonEmptyString(urlPolicy.redirectMode),
246 routeKind: routeKindForEntity(mapping),
247 };
248
249 if (route.routeKind === 'static') {
250 staticRoutes.push(route);
251 } else {
252 dynamicRoutes.push(route);
253 nativeDataSources.push({
254 sourceEntity: route.sourceEntity,
255 targetRef: route.targetRef,
256 targetDomain: route.targetDomain,
257 targetEntity: route.targetEntity,
258 targetClassification: route.targetClassification,
259 routeKind: route.routeKind,
260 sourceBasePath: route.sourceBasePath,
261 targetBasePath: route.targetBasePath,
262 preserveSlug: route.preserveSlug,
263 });
264 }
265
266 slugPolicies.push({
267 sourceEntity: route.sourceEntity,
268 sourceBasePath: route.sourceBasePath,
269 sourceSlugField: route.sourceSlugField,
270 targetBasePath: route.targetBasePath,
271 targetSlugField: route.targetSlugField,
272 preserveBasePath: route.preserveBasePath,
273 preserveSlug: route.preserveSlug,
274 });
275
276 if (route.redirectMode && route.redirectMode !== 'none') {
277 redirectIntents.push({
278 sourceEntity: route.sourceEntity,
279 sourceBasePath: route.sourceBasePath,
280 targetBasePath: route.targetBasePath,
281 redirectMode: route.redirectMode,
282 });
283 }
284 }
285
286 return {
287 routeIntent: {
288 staticRoutes,
289 dynamicRoutes,
290 redirectIntents,
291 slugPolicies,
292 },
293 nativeDataSources: uniqueBy(nativeDataSources, (item) =>
294 [item.targetRef, item.sourceEntity, item.sourceBasePath].filter(Boolean).join('|')),
295 };
296}
297
298function normalizeRequirementBinding(requirement) {
299 if (!requirement || typeof requirement !== 'object') return null;
300 return {
301 requirementId: firstNonEmptyString(requirement.requirementId, requirement.id),
302 requirementClass: firstNonEmptyString(requirement.requirementClass, requirement.class),
303 name: firstNonEmptyString(requirement.name),
304 requiredBy: ensureArray(requirement.requiredBy),
305 expectedState: requirement.expectedState || null,
306 automation: firstNonEmptyString(requirement.automation, requirement.automationMode),
307 verificationStatus: firstNonEmptyString(requirement.verificationStatus, requirement.status),
308 dependencyIds: ensureArray(requirement.dependencyIds),
309 notes: firstNonEmptyString(requirement.notes),
310 };
311}
312
313function extractCmsCollections(setupPlan, setupRequirements) {
314 const fromPlan = ensureArray(setupPlan && setupPlan.requiredCollections)
315 .map(normalizeCollectionRequirement)
316 .filter(Boolean);
317 const fromRequirements = readSetupRequirements(setupRequirements)
318 .filter((item) => {
319 const klass = firstNonEmptyString(item.requirementClass, item.class, item.type);
320 return klass && /collection/i.test(klass);
321 })
322 .map((item) => normalizeCollectionRequirement({
323 collectionName: firstNonEmptyString(
324 item.collectionName,
325 item.name,
326 item.expectedState && (item.expectedState.collectionName || item.expectedState.collectionId),
327 ),
328 collectionId: item.expectedState && firstNonEmptyString(item.expectedState.collectionId),
329 collectionPurpose: firstNonEmptyString(item.notes),
330 requiredByEntities: ensureArray(item.requiredBy),
331 fields: ensureArray(item.expectedState && item.expectedState.fields),
332 references: ensureArray(item.expectedState && item.expectedState.references),
333 automation: firstNonEmptyString(item.automation, item.automationMode),
334 verificationSource: firstNonEmptyString(item.verificationStatus, item.source),
335 }))
336 .filter(Boolean);
337 return uniqueBy([...fromPlan, ...fromRequirements], (item) => item.collectionId || item.collectionName);
338}
339
340function extractManualBlockers(setupRequirements, setupBlockers) {
341 const blockers = readSetupBlockers(setupBlockers).map((item) => ({
342 code: firstNonEmptyString(item.code),
343 severity: firstNonEmptyString(item.severity),
344 requirementId: firstNonEmptyString(item.requirementId),
345 description: firstNonEmptyString(item.description),
346 whyBlocked: firstNonEmptyString(item.whyBlocked),
347 recommendedAction: firstNonEmptyString(item.recommendedAction),
348 }));
349 const manualRequirements = readSetupRequirements(setupRequirements)
350 .filter((item) => {
351 const automation = firstNonEmptyString(item.automation, item.automationMode);
352 const verificationStatus = firstNonEmptyString(item.verificationStatus, item.status);
353 return automation === 'manual' || automation === 'blocked' || verificationStatus === 'blocked';
354 })
355 .map((item) => ({
356 code: firstNonEmptyString(item.requirementClass, item.class, item.type),
357 severity: firstNonEmptyString(item.severity, 'blocker'),
358 requirementId: firstNonEmptyString(item.requirementId, item.id),
359 description: firstNonEmptyString(item.name, item.notes),
360 whyBlocked: firstNonEmptyString(item.notes),
361 recommendedAction: firstNonEmptyString(item.recommendedAction),
362 }));
363 return uniqueBy([...blockers, ...manualRequirements], (item) =>
364 item.requirementId || `${item.code || 'blocker'}|${item.description || ''}`);
365}
366
367function extractMappingSummary(entityMappings, mappingPlan, mappingGaps) {
368 const entities = entityMappings.map((mapping) => ({
369 sourceEntity: firstNonEmptyString(mapping.sourceEntity, mapping.entityName),
370 sourceSemantics: firstNonEmptyString(mapping.sourceSemantics),
371 targetRef: firstNonEmptyString(mapping.targetRef),
372 targetDomain: firstNonEmptyString(mapping.targetDomain),
373 targetEntity: firstNonEmptyString(mapping.targetEntity),
374 targetClassification: firstNonEmptyString(mapping.targetClassification),
375 status: firstNonEmptyString(mapping.status),
376 urlPolicy: mapping.urlPolicy || null,
377 }));
378 const gaps = readMappingGaps(mappingPlan, mappingGaps).map((gap) => ({
379 code: firstNonEmptyString(gap.code),
380 severity: firstNonEmptyString(gap.severity),
381 entityName: firstNonEmptyString(gap.entityName, gap.sourceEntity),
382 gapType: firstNonEmptyString(gap.gapType),
383 description: firstNonEmptyString(gap.description),
384 recommendedHandling: firstNonEmptyString(gap.recommendedHandling),
385 }));
386 return { entities, gaps };
387}
388
389function extractLocale(sourceSchema) {
390 return firstNonEmptyString(
391 sourceSchema && sourceSchema.locale,
392 sourceSchema && sourceSchema.language,
393 sourceSchema && sourceSchema.sourceMeta && sourceSchema.sourceMeta.locale,
394 sourceSchema && sourceSchema.sourceMeta && sourceSchema.sourceMeta.language,
395 );
396}
397
398function extractDirection(sourceSchema) {
399 return firstNonEmptyString(
400 sourceSchema && sourceSchema.direction,
401 sourceSchema && sourceSchema.sourceMeta && sourceSchema.sourceMeta.direction,
402 );
403}
404
405function inferWebsiteScopeOptions(routeIntent, mappingSummary) {
406 const suggestions = [{ scope: 'home', reason: 'Always available as the minimum storefront continuation scope.' }];
407 const dynamicRoutes = ensureArray(routeIntent && routeIntent.dynamicRoutes);
408 const sourceEntities = new Set(dynamicRoutes.map((route) => route.sourceEntity).filter(Boolean));
409 const mappedEntities = new Set(ensureArray(mappingSummary && mappingSummary.entities).map((entity) => entity.sourceEntity).filter(Boolean));
410
411 const hasEcommerce = sourceEntities.has('product') || sourceEntities.has('product_category');
412 const hasBlog = sourceEntities.has('post') || sourceEntities.has('blog_category') || sourceEntities.has('blog_tag');
413 const hasCms = Array.from(mappedEntities).some((name) => /^page$|cms|resource|guide/i.test(String(name)));
414
415 if (hasEcommerce) {
416 suggestions.push({ scope: 'ecommerce', reason: 'Stores product and/or category routes were discovered in the migration mapping.' });
417 }
418 if (hasBlog) {
419 suggestions.push({ scope: 'blog', reason: 'Blog or knowledge-base post routes were discovered in the migration mapping.' });
420 }
421 if (hasEcommerce || hasBlog || hasCms) {
422 suggestions.push({ scope: 'full', reason: 'Multiple public route families were discovered, so a fuller storefront clone may be appropriate.' });
423 }
424 suggestions.push({ scope: 'specific', reason: 'Use explicit URLs when the user wants a curated subset of pages instead of a broad area.' });
425 return uniqueBy(suggestions, (item) => item.scope);
426}
427
428function buildFrontendProjectDir(projectDir, frontendConfigPath) {
429 if (frontendConfigPath) {
430 return rel(projectDir, path.dirname(frontendConfigPath));
431 }
432 return 'frontend';
433}
434
435function createInputFreshness(projectDir) {
436 const artifacts = {};
437 for (const [name, relativePath] of Object.entries(HANDOFF_ARTIFACTS)) {
438 artifacts[name] = {
439 path: relativePath,
440 sha256: hashArtifact(projectDir, relativePath),
441 };
442 }
443 return {
444 schemaVersion: FRESHNESS_SCHEMA_VERSION,
445 generatedAt: new Date().toISOString(),
446 artifacts,
447 };
448}
449
450function compareInputFreshness(current, recorded) {
451 const changes = [];
452 if (!recorded || typeof recorded !== 'object') {
453 return { ok: false, stale: true, changes: [{ field: 'inputFreshness', reason: 'missing' }] };
454 }
455 if (recorded.schemaVersion !== FRESHNESS_SCHEMA_VERSION) {
456 changes.push({
457 field: 'inputFreshness.schemaVersion',
458 expected: recorded.schemaVersion,
459 actual: FRESHNESS_SCHEMA_VERSION,
460 });
461 }
462 for (const [name, currentEntry] of Object.entries(current.artifacts || {})) {
463 const recordedEntry = (recorded.artifacts && recorded.artifacts[name]) || {};
464 if (currentEntry.sha256 !== recordedEntry.sha256) {
465 changes.push({
466 field: `inputFreshness.artifacts.${name}.sha256`,
467 path: currentEntry.path,
468 expected: recordedEntry.sha256 || null,
469 actual: currentEntry.sha256 || null,
470 });
471 }
472 }
473 return {
474 ok: changes.length === 0,
475 stale: changes.length > 0,
476 changes,
477 };
478}
479
480function renderSummary(handoff) {
481 const lines = [
482 '# Website Handoff Summary',
483 '',
484 `Generated: ${handoff.generatedAt}`,
485 '',
486 `- Delivery mode: ${handoff.deliveryMode}`,
487 `- Website scope: ${handoff.websiteScope.selectedScope || 'not yet selected'}`,
488 `- Destination strategy: ${handoff.destination.siteStrategy || 'unknown'}`,
489 `- Destination site id: ${handoff.destination.siteId || 'not yet resolved'}`,
490 `- Frontend phase allowed now: ${handoff.frontendPhase.allowedNow}`,
491 '',
492 '## Scope Suggestions',
493 '',
494 ];
495
496 if (handoff.websiteScope.scopeSuggestions.length === 0) {
497 lines.push('- None.');
498 } else {
499 for (const suggestion of handoff.websiteScope.scopeSuggestions) {
500 lines.push(`- ${suggestion.scope}: ${suggestion.reason}`);
501 }
502 }
503
504 lines.push(
505 '',
506 '## Dynamic Route Families',
507 '',
508 );
509
510 if (handoff.routeIntent.dynamicRoutes.length === 0) {
511 lines.push('- None declared yet.');
512 } else {
513 for (const route of handoff.routeIntent.dynamicRoutes) {
514 lines.push(
515 `- ${route.sourceEntity || 'unknown'} -> ${route.targetRef || [route.targetDomain, route.targetEntity].filter(Boolean).join('/')} ` +
516 `(source base path: ${route.sourceBasePath || 'n/a'}, target base path: ${route.targetBasePath || 'deferred'})`,
517 );
518 }
519 }
520
521 lines.push('', '## Wix Bindings', '');
522 if (handoff.bindings.wixApps.length === 0) {
523 lines.push('- No Wix app bindings declared.');
524 } else {
525 for (const app of handoff.bindings.wixApps) {
526 lines.push(`- ${app.appName}${app.appDefId ? ` (${app.appDefId})` : ''}`);
527 }
528 }
529
530 lines.push('', '## CMS Collections', '');
531 if (handoff.bindings.cmsCollections.length === 0) {
532 lines.push('- No CMS collections declared.');
533 } else {
534 for (const collection of handoff.bindings.cmsCollections) {
535 lines.push(`- ${collection.collectionName}`);
536 }
537 }
538
539 lines.push('', '## Manual Blockers', '');
540 if (handoff.setupContract.manualBlockers.length === 0) {
541 lines.push('- None.');
542 } else {
543 for (const blocker of handoff.setupContract.manualBlockers) {
544 lines.push(`- ${blocker.requirementId || blocker.code || 'blocker'}: ${blocker.description || blocker.whyBlocked || 'see setup artifacts'}`);
545 }
546 }
547
548 return `${lines.join('\n')}\n`;
549}
550
551async function writeFileAtomic(filePath, text) {
552 await fs.mkdir(path.dirname(filePath), { recursive: true });
553 const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
554 await fs.writeFile(tempPath, text, 'utf8');
555 await fs.rename(tempPath, filePath);
556}
557
558async function generateWebsiteHandoff(projectDir) {
559 const handoffDir = path.join(projectDir, 'website');
560 const decisionsPath = path.join(projectDir, HANDOFF_ARTIFACTS.decisions);
561 const sourceSchemaPath = path.join(projectDir, HANDOFF_ARTIFACTS.sourceSchema);
562 const mappingPlanPath = path.join(projectDir, HANDOFF_ARTIFACTS.mappingPlan);
563 const mappingGapsPath = path.join(projectDir, HANDOFF_ARTIFACTS.mappingGaps);
564 const setupPlanPath = path.join(projectDir, HANDOFF_ARTIFACTS.setupPlan);
565 const setupRequirementsPath = path.join(projectDir, HANDOFF_ARTIFACTS.setupRequirements);
566 const setupBlockersPath = path.join(projectDir, HANDOFF_ARTIFACTS.setupBlockers);
567 const wixEnvPath = path.join(projectDir, HANDOFF_ARTIFACTS.wixEnv);
568 const frontendConfigPath = path.join(projectDir, HANDOFF_ARTIFACTS.frontendConfig);
569 const completionReportPath = path.join(projectDir, HANDOFF_ARTIFACTS.completionReport);
570
571 const decisions = await readJsonIfExists(decisionsPath);
572 let sourceSchema = await readJsonIfExists(sourceSchemaPath);
573 let mappingPlan = await readJsonIfExists(mappingPlanPath);
574 const quickPlan = await readJsonIfExists(path.join(projectDir, 'quick-mode', 'plan.json'));
575 const mappingGaps = await readJsonIfExists(mappingGapsPath);
576 const setupPlan = await readJsonIfExists(setupPlanPath);
577 const setupRequirements = await readJsonIfExists(setupRequirementsPath);
578 const setupBlockers = await readJsonIfExists(setupBlockersPath);
579 const wixEnv = await readEnvIfExists(wixEnvPath);
580 const frontendConfig = await readJsonIfExists(frontendConfigPath);
581 const completionReportExists = await pathExists(completionReportPath);
582
583 const deliveryMode = firstNonEmptyString(
584 getDecisionValue(decisions || {}, 'deliveryMode'),
585 wixEnv.WIX_DELIVERY_MODE,
586 );
587 if (!deliveryMode) {
588 throw new Error('website handoff requires deliveryMode to be decided');
589 }
590 if (!sourceSchema && quickPlan) sourceSchema = { sourceUrl: quickPlan.sourceUrl, platform: quickPlan.adapter?.id?.replace(/^quick-/, '') };
591 if (!mappingPlan && quickPlan) mappingPlan = { entities: (quickPlan.entities || []).map((entity) => ({ sourceEntity: entity.id, target: entity.target })) };
592 if (!sourceSchema) {
593 throw new Error('website handoff requires source-schema.json');
594 }
595 if (!mappingPlan) {
596 throw new Error('website handoff requires mapping/mapping-plan.json');
597 }
598 if (!setupPlan) {
599 throw new Error('website handoff requires setup/setup-plan.json');
600 }
601 if (!setupRequirements) {
602 throw new Error('website handoff requires setup/setup-requirements.json');
603 }
604
605 const entityMappings = readEntityMappings(mappingPlan);
606 const apps = uniqueBy(
607 ensureArray(setupPlan.requiredApps).map(normalizeAppRequirement).filter(Boolean),
608 (item) => item.appDefId || item.appName,
609 );
610 const { routeIntent, nativeDataSources } = normalizeRouteIntent(entityMappings);
611 const cmsCollections = extractCmsCollections(setupPlan, setupRequirements);
612 const mappingSummary = extractMappingSummary(entityMappings, mappingPlan, mappingGaps);
613 const manualBlockers = extractManualBlockers(setupRequirements, setupBlockers);
614 const frontendConfigAbsolute = frontendConfig ? frontendConfigPath : null;
615 const inputFreshness = createInputFreshness(projectDir);
616 const automationMode = isExplicitUserOneClick(decisions || {}) ? 'one_click' : 'manual';
617 const faceliftMode = getDecisionValue(decisions || {}, 'faceliftMode');
618 const scopeDecision = normalizeScopeValue(getDecisionValue(decisions || {}, 'websiteScope'));
619 const scopeSuggestions = inferWebsiteScopeOptions(routeIntent, mappingSummary);
620 const autoSelectedScope = !scopeDecision.scope && deliveryMode === 'management_and_website' && automationMode === 'one_click'
621 ? chooseAutoScope(scopeSuggestions)
622 : null;
623 const selectedScope = scopeDecision.scope || autoSelectedScope;
624
625 const handoff = {
626 schemaVersion: SCHEMA_VERSION,
627 version: 1,
628 generatedAt: new Date().toISOString(),
629 migrationProject: {
630 name: path.basename(projectDir),
631 root: inferMigrationProjectRoot(projectDir),
632 },
633 source: {
634 url: firstNonEmptyString(
635 sourceSchema.sourceUrl,
636 getDecisionValue(decisions || {}, 'sourceUrl'),
637 ),
638 platform: firstNonEmptyString(
639 sourceSchema.platform,
640 getDecisionValue(decisions || {}, 'sourcePlatform'),
641 ),
642 locale: extractLocale(sourceSchema),
643 direction: extractDirection(sourceSchema),
644 },
645 deliveryMode,
646 automationMode: automationMode || 'manual',
647 facelift: {
648 requested: faceliftMode === 'requested',
649 requestedBy: faceliftMode === 'requested' ? 'user' : null,
650 constraints: ['preserve_brand_identity', 'preserve_site_structure', 'preserve_content'],
651 },
652 websiteScope: {
653 selectedScope,
654 explicitUrls: scopeDecision.explicitUrls,
655 scopeSuggestions,
656 selectedFromDecision: Boolean(scopeDecision.scope),
657 autoSelectedInOneClickMode: Boolean(autoSelectedScope),
658 defaultStandaloneScope: 'home',
659 },
660 destination: {
661 siteStrategy: normalizeSiteStrategy(
662 firstNonEmptyString(
663 getDecisionValue(decisions || {}, 'targetSiteStrategy'),
664 wixEnv.WIX_SITE_STRATEGY,
665 ),
666 ),
667 siteId: firstNonEmptyString(wixEnv.WIX_SITE_ID),
668 appId: firstNonEmptyString(
669 frontendConfig && (frontendConfig.appId || frontendConfig.applicationId),
670 wixEnv.WIX_APP_ID,
671 ),
672 frontendProjectDir: buildFrontendProjectDir(projectDir, frontendConfigAbsolute),
673 },
674 frontendPhase: {
675 allowedNow: completionReportExists ? 'build' : 'plan',
676 buildAllowedAfter: 'backend-import-complete',
677 parallelPlanAllowed: true,
678 recommendedParallelExecution: 'subagent_after_handoff',
679 },
680 routeIntent,
681 bindings: {
682 wixApps: apps,
683 cmsCollections,
684 nativeDataSources,
685 },
686 setupContract: {
687 requiredApps: apps,
688 collections: cmsCollections,
689 manualBlockers,
690 },
691 mappingSummary,
692 artifacts: {
693 mappingPlan: HANDOFF_ARTIFACTS.mappingPlan,
694 setupRequirements: HANDOFF_ARTIFACTS.setupRequirements,
695 setupPlan: HANDOFF_ARTIFACTS.setupPlan,
696 urlPreservationState: 'state/url-preservation/',
697 },
698 inputFreshness,
699 handoffFingerprint: handoffFingerprint(inputFreshness),
700 };
701
702 const summary = renderSummary(handoff);
703 const handoffPath = path.join(handoffDir, 'handoff.json');
704 const summaryPath = path.join(handoffDir, 'handoff-summary.md');
705 await writeFileAtomic(handoffPath, `${stableJson(handoff)}\n`);
706 await writeFileAtomic(summaryPath, summary);
707
708 return {
709 handoffPath,
710 summaryPath,
711 handoff,
712 };
713}
714
715async function validateWebsiteHandoff(projectDir) {
716 const handoffPath = path.join(projectDir, 'website', 'handoff.json');
717 const handoff = await readJsonIfExists(handoffPath);
718 if (!handoff) {
719 return {
720 ok: false,
721 present: false,
722 stale: true,
723 changes: [{ field: 'website/handoff.json', reason: 'missing' }],
724 handoff: null,
725 };
726 }
727 const current = createInputFreshness(projectDir);
728 const comparison = compareInputFreshness(current, handoff.inputFreshness);
729 return {
730 ok: comparison.ok,
731 present: true,
732 stale: comparison.stale,
733 changes: comparison.changes,
734 handoff,
735 current,
736 };
737}
738
739module.exports = {
740 SCHEMA_VERSION,
741 HANDOFF_ARTIFACTS,
742 handoffFingerprint,
743 createInputFreshness,
744 compareInputFreshness,
745 generateWebsiteHandoff,
746 validateWebsiteHandoff,
747};