Setting the file. One moment.
Internal Link Reconciliation · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page Bundled file Link Audit
lib/ internal-link-reconciliation.js
JavaScript · 166 lines · 11 KB
)
=>
new
URL
(
`https://${
host
}`
).host.
toLowerCase
()));
8 }
9
10 function classifyNavigation ( value , containingUrl , { sourceHosts = [], destinationHosts = [] } = {}) {
11 if ( typeof value !== 'string' || ! value) return { kind: 'invalid' };
12 if ( / ^ (?:javascript | data | mailto | tel):/ i . test (value)) return { kind: 'action' , value };
13 let url;
14 try { url = new URL (value, containingUrl); } catch { return { kind: 'invalid' , value }; }
15 if ( ! [ 'http:' , 'https:' ]. includes (url.protocol)) return { kind: 'external' , value };
16 const hosts = exactHosts ([ ... sourceHosts, ... destinationHosts]);
17 if ( ! hosts. has (url.host. toLowerCase ())) return { kind: 'external' , value };
18 const path = `${ url . pathname }${ url . search }${ url . hash }` ;
19 const stateful = / ^ \/ (?:wp-admin | wp-login | account \/ login | checkout \/ order-pay)(?: \/ |$ )/ i . test (url.pathname)
20 || [ ... url.searchParams. keys ()]. some (( key ) => / ^ (?:nonce | _wpnonce | token | auth | session | add-to-cart | remove_item | order-pay) $ / i . test (key));
21 if (stateful) return { kind: 'stateful' , original: value, path, host: url.host. toLowerCase () };
22 return { kind: 'internal' , original: value, path, lookupPath: `${ url . pathname }${ url . search }` , host: url.host. toLowerCase (), fragmentOnly: value. startsWith ( '#' ) };
23 }
24
25 function occurrenceId ({ sourceFile , sourceRecordId = '' , field , location , original }) {
26 return createHash ( 'sha256' ). update ([sourceFile, sourceRecordId, field, location, original]. join ( ' \0 ' )). digest ( 'hex' ). slice ( 0 , 24 );
27 }
28
29 function verifiedDestination ( row ) {
30 if ( ! row || ! row.targetRecordId || ! row.path || ! row.pageFamily || ! row.deploymentId || ! row.routeRevision || ! row.checkedAt || row.identityMatched !== true || row.rendered !== true ) {
31 throw new Error ( 'verified destination requires target identity, served path and current deployment evidence' );
32 }
33 if ( ! row.path. startsWith ( '/' ) || row.path. startsWith ( '//' )) throw new Error ( 'verified path must be root-relative' );
34 return row;
35 }
36
37 function writerGuard ( field , activeWriters , recordType ) {
38 if (recordType === 'blog-post' ) return { ok: false , reason: 'blog-writer-integration-pending' };
39 if ( ! activeWriters || activeWriters.complete !== true || ! Array. isArray (activeWriters.writers) || activeWriters.writers. some (( row ) => ! row || ! Array. isArray (row.fields))) {
40 return { ok: false , reason: 'unknown-writer-contract' };
41 }
42 const touching = activeWriters.writers. filter (( row ) => row.fields. includes (field));
43 if (touching. length !== 1 || touching[ 0 ].writer !== 'internal-link-reconciler' || touching[ 0 ].compatible !== true || touching[ 0 ].runsAfter !== 'post-frontend-verification' ) {
44 return { ok: false , reason: 'incompatible-writer-contract' };
45 }
46 return { ok: true };
47 }
48
49 // Mutation is per logical record. A guarded field may proceed while another field is held.
50 // The adapter owns native field serialization; this function never edits blog post fields.
51 async function reconcileRecord ({ recordId , recordType , fields , destinations , activeWriters , destinationState , adapter , journal , intentKey = null , priorIntentKeys = [] }) {
52 if ( ! adapter || typeof adapter.patchField !== 'function' || ! journal || ! recordId) throw new Error ( 'record, structured patch adapter and journal required' );
53 if (destinationState !== 'migration-not-live' ) throw new Error ( 'content writes require a confirmed non-live migration destination' );
54 const blocked = [];
55 const eligible = [];
56 for ( const field of fields) {
57 const guard = writerGuard ( `${ recordType }:${ field . name }` , activeWriters, recordType);
58 if ( ! guard.ok) { blocked. push ({ field: field.name, occurrenceIds: field.occurrenceIds, reason: guard.reason }); continue ; }
59 const destination = field.patches ? null : destinations[field.destinationId];
60 if (field.patches) {
61 if ( ! field.patches. length || field.patches. some (( patch ) => {
62 try { verifiedDestination (destinations[patch.destinationId]); return false ; } catch { return true ; }
63 })) {
64 blocked. push ({ field: field.name, occurrenceIds: field.occurrenceIds, reason: 'destination-unverified' }); continue ;
65 }
66 eligible. push ({ field, destination: null });
67 continue ;
68 }
69 if (field.unresolvedNormalization === true ) {
70 if ( ! destination || ! destination.path || ! destination.path. startsWith ( '/' ) || destination.path. startsWith ( '//' )) {
71 blocked. push ({ field: field.name, occurrenceIds: field.occurrenceIds, reason: 'unresolved-original-path-invalid' }); continue ;
72 }
73 } else {
74 try { verifiedDestination (destination); } catch {
75 blocked. push ({ field: field.name, occurrenceIds: field.occurrenceIds, reason: 'destination-unverified' }); continue ;
76 }
77 }
78 eligible. push ({ field, destination });
79 }
80 if ( ! eligible. length ) return { recordId, blocked, written: [] };
81 const current = await adapter. read (recordId);
82 if ( ! current || ! current.revision) throw new Error ( 'content read needs revision' );
83 if (intentKey && typeof journal.findCompleted === 'function' ) {
84 const completed = await journal. findCompleted (recordId, intentKey);
85 if (completed) {
86 if ( ! completed.changes. every (( change ) => isDeepStrictEqual (current.fields[change.name], change.after))) throw new Error ( 'completed link repair conflicts with changed content' );
87 return { recordId, blocked, written: completed.changes. map (( change ) => change.name), resumed: true , contentRevision: current.revision };
88 }
89 }
90 if (intentKey && typeof journal.findOpen === 'function' ) {
91 const open = await journal. findOpen (recordId, intentKey);
92 if (open) {
93 if (open.changes. every (( change ) => isDeepStrictEqual (current.fields[change.name], change.after))) {
94 await journal. complete (open);
95 return { recordId, blocked, written: open.changes. map (( change ) => change.name), resumed: true , contentRevision: current.revision };
96 }
97 if (current.revision !== open.baseRevision) throw new Error ( 'uncertain write conflicts with changed content' );
98 await adapter. write (recordId, Object. fromEntries (open.changes. map (( change ) => [change.name, change.after])), current.revision);
99 const readBack = await adapter. read (recordId);
100 if ( ! open.changes. every (( change ) => isDeepStrictEqual (readBack.fields[change.name], change.after))) throw new Error ( 'content write read-back mismatch' );
101 await journal. complete (open);
102 return { recordId, blocked, written: open.changes. map (( change ) => change.name), resumed: true , contentRevision: readBack.revision };
103 }
104 }
105 // A frontend redeploy invalidates served-link evidence, not a completed content write.
106 // Reuse the exact journaled after-image only when it still matches the current record.
107 if ( typeof journal.findCompleted === 'function' ) {
108 for ( const priorKey of priorIntentKeys) {
109 const completed = await journal. findCompleted (recordId, priorKey);
110 if ( ! completed || ! eligible. every (({ field }) => completed.changes. some (( change ) =>
111 change.name === field.name && field.occurrenceIds. every (( id ) => change.occurrenceIds. includes (id)))) ||
112 ! completed.changes. every (( change ) => isDeepStrictEqual (current.fields[change.name], change.after))) continue ;
113 return { recordId, blocked, written: eligible. map (({ field }) => field.name), resumed: true , contentRevision: current.revision, priorIntentKey: priorKey };
114 }
115 }
116 for ( const { field } of eligible) {
117 if (field.expectedRevision && field.expectedRevision !== current.revision) {
118 blocked. push ({ field: field.name, occurrenceIds: field.occurrenceIds, reason: 'concurrent-edit' });
119 }
120 }
121 if (blocked. some (( row ) => row.reason === 'concurrent-edit' )) return { recordId, blocked, written: [] };
122 const intended = eligible. map (({ field , destination }) => {
123 const baseValue = field.baseValue === undefined ? current.fields[field.name] : field.baseValue;
124 let patch;
125 if (field.patches) {
126 const ordered = [ ... field.patches]. sort (( a , b ) => {
127 const offset = ( row ) => Number ( /char:( \d + )/ . exec (row.occurrence.location || '' )?.[ 1 ] || 0 );
128 return offset (b) - offset (a);
129 });
130 patch = { value: baseValue, preservedUnrelated: true , changedLocations: [] };
131 for ( const row of ordered) {
132 const next = adapter. patchField (patch.value, row.occurrence, row.path);
133 if ( ! next || next.preservedUnrelated !== true || ! next.changedLocations?. length ) throw new Error ( `field ${ field . name } lacks a verified structured link patch` );
134 patch = { value: next.value, preservedUnrelated: true , changedLocations: [ ... patch.changedLocations, ... next.changedLocations] };
135 }
136 } else patch = adapter. patchField (baseValue, field, destination.path);
137 if ( ! patch || patch.preservedUnrelated !== true || ! Array. isArray (patch.changedLocations) || ! patch.changedLocations. length ) {
138 throw new Error ( `field ${ field . name } lacks a verified structured link patch` );
139 }
140 return { name: field.name, before: baseValue, after: patch.value, occurrenceIds: field.occurrenceIds, changedLocations: patch.changedLocations };
141 });
142 const attempt = intentKey ? null : await journal. find (recordId, intended);
143 if (attempt && intended. every (( change ) => isDeepStrictEqual (current.fields[change.name], change.after))) {
144 await journal. complete (attempt);
145 return { recordId, blocked, written: intended. map (( change ) => change.name), resumed: true };
146 }
147 if (attempt && current.revision !== attempt.baseRevision) throw new Error ( 'uncertain write conflicts with changed content' );
148 const changes = [];
149 for ( const { field , destination } of eligible) {
150 if ( ! isDeepStrictEqual (current.fields[field.name], field.baseValue === undefined ? current.fields[field.name] : field.baseValue)) {
151 blocked. push ({ field: field.name, occurrenceIds: field.occurrenceIds, reason: 'concurrent-edit' });
152 } else {
153 const intendedChange = intended. find (( row ) => row.name === field.name);
154 if ( ! isDeepStrictEqual (current.fields[field.name], intendedChange.after)) changes. push (intendedChange);
155 }
156 }
157 if ( ! changes. length ) return { recordId, blocked, written: [] };
158 const started = attempt || await journal. start (recordId, current.revision, changes, intentKey);
159 await adapter. write (recordId, Object. fromEntries (changes. map (( change ) => [change.name, change.after])), current.revision);
160 const readBack = await adapter. read (recordId);
161 if ( ! changes. every (( change ) => isDeepStrictEqual (readBack.fields[change.name], change.after))) throw new Error ( 'content write read-back mismatch' );
162 await journal. complete (started);
163 return { recordId, blocked, written: changes. map (( change ) => change.name), contentRevision: readBack.revision };
164 }
165
166 module . exports = { classifyNavigation, occurrenceId, verifiedDestination, writerGuard, reconcileRecord };