Setting the file. One moment.
Yoast Product SEO Evidence · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 39.40
Position 40 of 92
Type JavaScript
Size 13 KB
Lines 272 lib/ yoast-product-seo-evidence.js
JavaScript · 272 lines · 13 KB
15 const FIELD_KEYS = Object. freeze ({
16 title: '_yoast_wpseo_title' ,
17 description: '_yoast_wpseo_metadesc' ,
18 focusKeyword: '_yoast_wpseo_focuskw' ,
19 });
20 // Exact allow-list; never widened by key discovery.
21 const ALLOWED_KEYS = Object. freeze (Object. values ( FIELD_KEYS ));
22 const SEO_FIELDS = Object. freeze (Object. keys ( FIELD_KEYS ));
23 const HEAD_FIELD = Object. freeze ({ title: 'title' , description: 'description' });
24 const STATES = Object. freeze ([ 'override' , 'inherit' , 'unknown' ]);
25
26 // A post_id list is sent inline in the `where` clause, so keep one batch well inside a request.
27 const DEFAULT_BRIDGE_BATCH = 50 ;
28 const MAX_BRIDGE_BATCH = 200 ;
29 const MAX_BRIDGE_PAGES = 1000 ;
30
31 function isBlank ( value ) {
32 return value == null || String (value). trim () === '' ;
33 }
34
35 const NAMED_ENTITIES = Object. freeze ({
36 amp: '&' , lt: '<' , gt: '>' , quot: '"' , apos: "'" , nbsp: ' ' , ndash: '–' , mdash: '—' ,
37 hellip: '…' , lsquo: '‘' , rsquo: '’' , ldquo: '“' , rdquo: '”' , laquo: '«' ,
38 raquo: '»' , copy: '©' , reg: '®' , trade: '™' , middot: '·' , bull: '•' ,
39 });
40
41 // Exactly ONE decoding pass. `&amp;` becomes `&`, never `&`: decoding repeatedly would
42 // corrupt a title that legitimately contains an entity-looking string.
43 function decodeEntitiesOnce ( value ) {
44 return String (value). replace ( /&(#x [0-9a-f] +| # \d +| [a-z] + );/ gi , ( match , body ) => {
45 if (body[ 0 ] === '#' ) {
46 const code = body[ 1 ] === 'x' || body[ 1 ] === 'X' ? parseInt (body. slice ( 2 ), 16 ) : parseInt (body. slice ( 1 ), 10 );
47 return Number. isInteger (code) && code > 0 && code <= 0x10ffff ? String. fromCodePoint (code) : match;
48 }
49 const named = NAMED_ENTITIES [body. toLowerCase ()];
50 return named === undefined ? match : named;
51 });
52 }
53
54 function sanitizeRendered ( value ) {
55 if ( typeof value !== 'string' ) return null ;
56 const decoded = decodeEntitiesOnce (value). trim ();
57 return decoded === '' ? null : decoded;
58 }
59
60 // Channel 1: the product post's REST `meta` object. A key counts only when `meta` contains it;
61 // a response without the key (not registered for the product type, or unauthenticated) leaves
62 // that key unresolved -- never "no override" -- so it can fall through to the bridge.
63 function restEvidence ({ response , path = null } = {}) {
64 const meta = response && typeof response.meta === 'object' && ! Array. isArray (response.meta) ? response.meta : null ;
65 const out = {};
66 for ( const key of ALLOWED_KEYS ) {
67 if (meta && Object . prototype .hasOwnProperty. call (meta, key)) {
68 out[key] = { resolved: true , raw: meta[key], evidenceRef: { path, keyPresentInMeta: true } };
69 } else {
70 out[key] = { resolved: false , reason: meta ? 'key-absent-from-rest-meta' : 'rest-meta-unavailable' , evidenceRef: { path } };
71 }
72 }
73 return out;
74 }
75
76 function pinnedKeysOf ( eavAccess , keyColumn ) {
77 if ( ! eavAccess) return null ;
78 const pairs = [eavAccess, ... (eavAccess.additionalPairs || [])];
79 const pair = pairs. find (( p ) => p && p.keyColumn === keyColumn);
80 if ( ! pair || pair.tier !== 'key-scoped' || ! Array. isArray (pair.pinnedKeys)) return null ;
81 return pair.pinnedKeys;
82 }
83
84 // Channel 2: wix-wp-plugin-v2 key-scoped postmeta read, paginated to exhaustion.
85 // `queryPage({ structureRequest, cursor })` resolves to queryStructure's `{ rows, pagingMetadata,
86 // eavAccess }`. A batch is COMPLETE only when every page succeeded, the response proved a
87 // key-scoped read of every queried key, and the last page reported no next cursor. Anything else
88 // leaves the batch incomplete -- and only that batch.
89 async function readBridgeEvidence ({ queryPage , table , productIds , keys = ALLOWED_KEYS , batchSize = DEFAULT_BRIDGE_BATCH , keyColumn = 'meta_key' , valueColumn = 'meta_value' , idColumn = 'post_id' , orderColumn = 'meta_id' } = {}) {
90 if ( typeof queryPage !== 'function' ) throw new Error ( 'readBridgeEvidence: queryPage is required' );
91 if ( ! table || typeof table !== 'string' ) throw new Error ( 'readBridgeEvidence: table (the prefixed postmeta table) is required' );
92 if ( ! Array. isArray (productIds)) throw new Error ( 'readBridgeEvidence: productIds must be an array' );
93 if ( ! Number. isInteger (batchSize) || batchSize < 1 || batchSize > MAX_BRIDGE_BATCH ) {
94 throw new Error ( `readBridgeEvidence: batchSize must be 1..${ MAX_BRIDGE_BATCH }` );
95 }
96 const wanted = [ ...new Set (keys)];
97 if ( ! wanted. length || wanted. some (( key ) => ! ALLOWED_KEYS . includes (key))) {
98 throw new Error ( `readBridgeEvidence: keys must be a non-empty subset of ${ ALLOWED_KEYS . join ( ', ' ) }` );
99 }
100 const ids = [ ...new Set (productIds. map (String))];
101 const batches = [];
102 for ( let start = 0 , index = 0 ; start < ids. length ; start += batchSize, index += 1 ) {
103 const batchIds = ids. slice (start, start + batchSize);
104 const structureRequest = {
105 table,
106 select: [{ column: orderColumn }, { column: idColumn }, { column: keyColumn }, { column: valueColumn }],
107 where: [
108 { column: keyColumn, op: 'in' , value: wanted },
109 { column: idColumn, op: 'in' , value: batchIds. map (( id ) => ( / ^ \d +$ / . test (id) ? Number (id) : id)) },
110 ],
111 orderBy: [{ column: orderColumn, direction: 'asc' }],
112 };
113 const batch = { batchId: `yoast-bridge-${ index }` , productIds: batchIds, keys: wanted, pageCount: 0 , complete: false , finalHasNext: null , reason: null , failedPage: null , rows: [] };
114 let cursor;
115 for (;;) {
116 if (batch.pageCount >= MAX_BRIDGE_PAGES ) { batch.reason = 'page-limit-exceeded' ; break ; }
117 let page;
118 try {
119 page = await queryPage ({ structureRequest, cursor });
120 } catch (error) {
121 batch.failedPage = batch.pageCount + 1 ;
122 batch.reason = `page ${ batch . failedPage } failed: ${ error && error . message ? error . message : String ( error ) }` ;
123 break ;
124 }
125 batch.pageCount += 1 ;
126 const pinned = pinnedKeysOf (page && page.eavAccess, keyColumn);
127 if ( ! pinned || wanted. some (( key ) => ! pinned. includes (key))) {
128 batch.failedPage = batch.pageCount;
129 batch.reason = `page ${ batch . pageCount } did not prove a key-scoped read of every queried key` ;
130 break ;
131 }
132 if ( ! page || ! Array. isArray (page.rows) || ! page.pagingMetadata || typeof page.pagingMetadata.hasNext !== 'boolean' ) {
133 batch.failedPage = batch.pageCount;
134 batch.reason = `page ${ batch . pageCount } had no rows/pagingMetadata` ;
135 break ;
136 }
137 for ( const row of page.rows) {
138 batch.rows. push ({ productId: String (row[idColumn]), key: row[keyColumn], value: row[valueColumn] });
139 }
140 batch.finalHasNext = page.pagingMetadata.hasNext;
141 if ( ! page.pagingMetadata.hasNext) { batch.complete = true ; break ; }
142 const next = page.pagingMetadata.cursors && page.pagingMetadata.cursors.next;
143 if ( ! next) { batch.reason = `page ${ batch . pageCount } reported hasNext without a cursor (truncated)` ; break ; }
144 cursor = next;
145 }
146 batches. push (batch);
147 }
148 return batches;
149 }
150
151 function resolveOverrideValue ( field , raw , yoastHeadJson ) {
152 if (field === 'focusKeyword' ) return { value: String (raw). trim () };
153 const rendered = sanitizeRendered (yoastHeadJson && yoastHeadJson[ HEAD_FIELD [field]]);
154 if (rendered !== null ) return { value: rendered };
155 const trimmed = String (raw). trim ();
156 if ( ! trimmed. includes ( '%%' )) return { value: trimmed };
157 return { unknown: 'override-has-yoast-variables-and-no-resolved-value' };
158 }
159
160 // The per-product audit receipt. It is stored with the extraction and is NOT hashed as a
161 // whole; only `seoHashProjection` (rp-target-wix/lib/product-seo.js) feeds the source hash.
162 function buildProductSeoReceipt ({ productId , capturedAt , rest = null , bridgeBatch = null , yoastHeadJson = null } = {}) {
163 if (productId == null || String (productId) === '' ) throw new Error ( 'buildProductSeoReceipt: productId is required' );
164 if ( ! capturedAt) throw new Error ( 'buildProductSeoReceipt: capturedAt is required' );
165 const id = String (productId);
166 const fields = {};
167 for ( const field of SEO_FIELDS ) {
168 const key = FIELD_KEYS [field];
169 const failures = [];
170 let raw;
171 let provenance = null ;
172 const restKey = rest && rest[key];
173 if (restKey && restKey.resolved) {
174 raw = restKey.raw;
175 provenance = { channel: 'rest' , readComplete: true , evidenceRef: { path: restKey.evidenceRef.path, keyPresentInMeta: true } };
176 } else {
177 failures. push (restKey ? `rest: ${ restKey . reason }` : 'rest: not read' );
178 if (bridgeBatch && bridgeBatch.complete && bridgeBatch.productIds. includes (id) && bridgeBatch.keys. includes (key)) {
179 const row = bridgeBatch.rows. find (( r ) => r.productId === id && r.key === key);
180 raw = row ? row.value : undefined ;
181 provenance = {
182 channel: 'bridge' ,
183 readComplete: true ,
184 evidenceRef: { batchId: bridgeBatch.batchId, productIds: bridgeBatch.productIds, pageCount: bridgeBatch.pageCount, finalHasNext: false , rowPresent: Boolean (row) },
185 };
186 } else {
187 failures. push ( ! bridgeBatch ? 'bridge: unavailable' : `bridge: ${ bridgeBatch . reason || 'key not read for this product'}` );
188 }
189 }
190 if ( ! provenance) {
191 fields[field] = { state: 'unknown' , rawPresent: false , provenance: { channel: 'none' , readComplete: false , evidenceRef: { failures } } };
192 continue ;
193 }
194 const rawPresent = raw !== undefined && raw !== null ;
195 if ( ! rawPresent || isBlank (raw)) {
196 fields[field] = { state: 'inherit' , rawPresent, provenance };
197 continue ;
198 }
199 const resolved = resolveOverrideValue (field, raw, yoastHeadJson);
200 if (resolved.unknown) {
201 fields[field] = { state: 'unknown' , rawPresent, provenance: { ... provenance, evidenceRef: { ... provenance.evidenceRef, failures: [resolved.unknown] } } };
202 } else {
203 fields[field] = { state: 'override' , rawPresent, value: resolved.value, provenance };
204 }
205 }
206 return { productId: id, capturedAt, fields };
207 }
208
209 // Receipts for a whole extraction. `products` rows: { id, yoast_head_json?, restResponse?,
210 // restPath? }. `bridgeBatches` is readBridgeEvidence's output (or [] when the bridge is absent).
211 function buildProductSeoReceipts ({ products , bridgeBatches = [], capturedAt } = {}) {
212 if ( ! Array. isArray (products)) throw new Error ( 'buildProductSeoReceipts: products must be an array' );
213 const batchFor = new Map ();
214 for ( const batch of bridgeBatches || []) for ( const id of batch.productIds) batchFor. set ( String (id), batch);
215 return products. map (( product ) => buildProductSeoReceipt ({
216 productId: product.id,
217 capturedAt,
218 rest: product.restResponse ? restEvidence ({ response: product.restResponse, path: product.restPath || null }) : null ,
219 bridgeBatch: batchFor. get ( String (product.id)) || null ,
220 yoastHeadJson: product.yoast_head_json || null ,
221 }));
222 }
223
224 // Canonical-record projection of a receipt (`product.seoEvidence`). `rendered` carries the
225 // sanitized rendered title/description ONLY so an owner's `materialize` choice has a value to
226 // write; it is never proof and never written without that choice.
227 function seoEvidenceFromReceipt ( receipt , { yoastHeadJson = null , permalink = null } = {}) {
228 const out = {};
229 for ( const field of SEO_FIELDS ) {
230 const f = receipt.fields[field];
231 out[field] = f.state === 'override' ? { state: 'override' , value: f.value } : { state: f.state };
232 }
233 out.rendered = {
234 title: sanitizeRendered (yoastHeadJson && yoastHeadJson.title),
235 description: sanitizeRendered (yoastHeadJson && yoastHeadJson.description),
236 };
237 out.canonical = classifyProductCanonical (yoastHeadJson, permalink);
238 return out;
239 }
240
241 // A computed self-canonical (the product's own source permalink) is never written: Wix
242 // generates its own. A canonical pointing anywhere else may be intentional, but REST cannot prove
243 // it and no policy for foreign canonicals is approved, so it is flagged, never written.
244 function normalizeUrl ( value ) {
245 try {
246 const url = new URL ( String (value));
247 return `${ url . host . toLowerCase () }${ decodeURI ( url . pathname ). replace ( / \/ +$ / , '' ) }` ;
248 } catch (e) {
249 return null ;
250 }
251 }
252 function classifyProductCanonical ( yoastHeadJson , permalink ) {
253 const canonical = yoastHeadJson && yoastHeadJson.canonical;
254 if ( isBlank (canonical)) return 'none' ;
255 if (permalink && normalizeUrl (canonical) && normalizeUrl (canonical) === normalizeUrl (permalink)) return 'self' ;
256 return 'pending_policy' ;
257 }
258
259 module . exports = {
260 FIELD_KEYS,
261 ALLOWED_KEYS,
262 SEO_FIELDS,
263 STATES,
264 DEFAULT_BRIDGE_BATCH,
265 decodeEntitiesOnce,
266 restEvidence,
267 readBridgeEvidence,
268 buildProductSeoReceipt,
269 buildProductSeoReceipts,
270 seoEvidenceFromReceipt,
271 classifyProductCanonical,
272 };