Setting the file. One moment.
Wp Route Classifier · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 43.15
Position 15 of 46
Type JavaScript
Size 27 KB
Lines 625 lib/ wp-route-classifier.js
JavaScript · 625 lines · 27 KB
'excluded_integration'
,
13 'excluded_marketplace_setup' ,
14 'excluded_unsupported' ,
15 ]);
16
17 const FRONTEND_RULES = [
18 [ '/wp/v2/pages' , 'wp.frontend.pages' , 'site composition pages are outside backend data scope' ],
19 [ '/wp/v2/navigation' , 'wp.frontend.navigation' , 'navigation is site composition, not backend data' ],
20 [ '/wp/v2/menus' , 'wp.frontend.menus' , 'menus are site composition, not backend data' ],
21 [ '/wp/v2/menu-items' , 'wp.frontend.menu-items' , 'menu items are site composition, not backend data' ],
22 [ '/wp/v2/menu-locations' , 'wp.frontend.menu-locations' , 'menu locations are presentation configuration' ],
23 [ '/wp/v2/search' , 'wp.frontend.search' , 'search is a frontend lookup helper' ],
24 [ '/oembed/1.0/*' , 'wp.frontend.oembed' , 'oEmbed routes are frontend embedding helpers' ],
25 ];
26
27 const TEMPLATE_EDITOR_RULES = [
28 [ '/wp/v2/templates*' , 'wp.editor.templates' , 'templates are theme/editor construction state' ],
29 [ '/wp/v2/template-parts*' , 'wp.editor.template-parts' , 'template parts are theme/editor construction state' ],
30 [ '/wp/v2/blocks' , 'wp.editor.blocks' , 'blocks are editor construction state' ],
31 [ '/wp/v2/block-types' , 'wp.editor.block-types' , 'block types are editor metadata' ],
32 [ '/wp/v2/block-directory/*' , 'wp.editor.block-directory' , 'block directory routes are editor helpers' ],
33 [ '/wp/v2/block-patterns/*' , 'wp.editor.block-patterns' , 'block pattern routes are editor helpers' ],
34 [ '/wp/v2/pattern-directory/*' , 'wp.editor.pattern-directory' , 'pattern directory routes are editor helpers' ],
35 [ '/wp/v2/wp_pattern_category' , 'wp.editor.pattern-category' , 'pattern categories are editor helpers' ],
36 [ '/wp-block-editor/*' , 'wp.editor.namespace' , 'block editor namespace is outside backend data scope' ],
37 [ '/wp/v2/widgets' , 'wp.editor.widgets' , 'widgets are presentation configuration' ],
38 [ '/wp/v2/widget-types' , 'wp.editor.widget-types' , 'widget types are presentation metadata' ],
39 [ '/wp/v2/sidebars' , 'wp.editor.sidebars' , 'sidebars are presentation configuration' ],
40 [ '/wp/v2/themes' , 'wp.editor.themes' , 'themes are presentation configuration' ],
41 [ '/wp/v2/font-*' , 'wp.editor.fonts' , 'font routes describe presentation assets' ],
42 [ '/wp/v2/icons' , 'wp.editor.icons' , 'icons describe presentation assets' ],
43 ];
44
45 const RUNTIME_RULES = [
46 [ '/wc/store/cart*' , 'wc.runtime.cart' , 'store cart routes are customer-session state' ],
47 [ '/wc/store/checkout*' , 'wc.runtime.checkout' , 'store checkout routes are customer-session state' ],
48 [ '/wc/store/v1/cart*' , 'wc.runtime.v1.cart' , 'store cart routes are customer-session state' ],
49 [ '/wc/store/v1/checkout*' , 'wc.runtime.v1.checkout' , 'store checkout routes are customer-session state' ],
50 ];
51
52 const ADMIN_RULES = [
53 [ '/wc-admin/*' , 'wc.admin.namespace' , 'WooCommerce admin routes are dashboard surfaces' ],
54 [ '/wc-analytics/*' , 'wc.admin.analytics' , 'WooCommerce analytics routes are dashboard/reporting surfaces' ],
55 [ '/wc/v1/reports*' , 'wc.admin.reports.v1' , 'WooCommerce reports are analytics surfaces' ],
56 [ '/wc/v2/reports*' , 'wc.admin.reports.v2' , 'WooCommerce reports are analytics surfaces' ],
57 [ '/wc/v3/reports*' , 'wc.admin.reports.v3' , 'WooCommerce reports are analytics surfaces' ],
58 ];
59
60 const MARKETPLACE_RULES = [
61 [ '/wc/v3/marketplace/*' , 'wc.setup.marketplace' , 'WooCommerce marketplace routes are setup surfaces' ],
62 [ '/wc/v3/system_status*' , 'wc.setup.system-status' , 'WooCommerce system status routes are operational diagnostics' ],
63 [ '/wc/v3/settings' , 'wc.setup.settings' , 'WooCommerce settings are setup/configuration surfaces' ],
64 [ '/wc/v3/wc_paypal/*' , 'wc.setup.paypal' , 'WooCommerce PayPal routes are provider setup surfaces' ],
65 [ '/wc/v3/wc_stripe/*' , 'wc.setup.stripe' , 'WooCommerce Stripe routes are provider setup surfaces' ],
66 [ '/wccom-site/*' , 'wc.setup.wccom-site' , 'WooCommerce.com routes are marketplace/setup surfaces' ],
67 [ '/wc/gla/*' , 'wc.setup.google-listings' , 'Google Listings and Ads routes are setup/reporting surfaces' ],
68 ];
69
70 const DIAGNOSTIC_RULES = [
71 [ '/wp-site-health/*' , 'wp.diagnostics.site-health' , 'site health routes are diagnostics' ],
72 [ '/wp-abilities/*' , 'wp.diagnostics.abilities' , 'abilities routes are capability discovery' ],
73 [ '/serviceapp/*' , 'wp.diagnostics.serviceapp' , 'service app routes are operational surfaces' ],
74 [ '/vip/*' , 'wp.diagnostics.vip' , 'VIP routes are operational surfaces' ],
75 ];
76
77 const INTEGRATION_RULES = [
78 [ '/jetpack/*' , 'wp.integration.jetpack' , 'Jetpack routes are external service or site operations surfaces' ],
79 [ '/post-smtp/*' , 'wp.integration.post-smtp' , 'SMTP routes are mail operations surfaces' ],
80 [ '/redirection/*' , 'wp.integration.redirection' , 'redirection routes are operational helper surfaces' ],
81 [ '/yoast/*' , 'wp.integration.yoast' , 'SEO helper routes are outside backend data scope' ],
82 [ '/klaviyo/*' , 'wp.integration.klaviyo' , 'marketing integration routes are outside backend data scope' ],
83 [ '/kb-fluentcrm/*' , 'wp.integration.fluentcrm' , 'marketing integration routes are outside backend data scope' ],
84 [ '/kb-getresponse/*' , 'wp.integration.getresponse' , 'marketing integration routes are outside backend data scope' ],
85 [ '/kb-mailerlite/*' , 'wp.integration.mailerlite' , 'marketing integration routes are outside backend data scope' ],
86 [ '/kb-design-library/*' , 'wp.integration.design-library' , 'design library routes are presentation helpers' ],
87 [ '/kbp/*' , 'wp.integration.kbp' , 'AI or pattern helper routes are outside backend data scope' ],
88 ];
89
90 const BACKEND_DATA_RULES = [
91 [ '/wp/v2/posts' , 'wp.data.posts' , 'posts are durable content records' ],
92 [ '/wp/v2/media' , 'wp.data.media' , 'media are durable asset records' ],
93 [ '/wp/v2/categories' , 'wp.data.categories' , 'categories are durable taxonomy records' ],
94 [ '/wp/v2/tags' , 'wp.data.tags' , 'tags are durable taxonomy records' ],
95 [ '/wp/v2/comments' , 'wp.data.comments' , 'comments are durable content records' ],
96 [ '/wc/v3/products/attributes*' , 'wc.data.product-attributes' , 'WooCommerce product attributes are canonical store data' ],
97 [ '/wc/v3/products/categories*' , 'wc.data.product-categories' , 'WooCommerce product categories are canonical store data' ],
98 [ '/wc/v3/products/tags*' , 'wc.data.product-tags' , 'WooCommerce product tags are canonical store data' ],
99 [ '/wc/v3/products/shipping_classes*' , 'wc.data.shipping-classes' , 'WooCommerce shipping classes are canonical store data' ],
100 [ '/wc/v3/products*' , 'wc.data.products' , 'WooCommerce products are canonical store data' ],
101 [ '/wc/v3/coupons*' , 'wc.data.coupons' , 'WooCommerce coupons are canonical store data' ],
102 [ '/wc/v3/orders*' , 'wc.data.orders' , 'WooCommerce orders are canonical store data' ],
103 [ '/wc/v3/refunds*' , 'wc.data.refunds' , 'WooCommerce refunds are canonical store data' ],
104 [ '/wc/v3/customers*' , 'wc.data.customers' , 'WooCommerce customers are canonical store data' ],
105 // Must precede /wc/v3/taxes* (first-match-wins) — it's a prefix of that route.
106 [ '/wc/v3/taxes/classes*' , 'wc.data.tax-classes' , 'WooCommerce tax classes are canonical store data' ],
107 [ '/wc/v3/taxes*' , 'wc.data.taxes' , 'WooCommerce taxes are canonical store data' ],
108 [ '/wc/v3/shipping/zones' , 'wc.data.shipping-zones' , 'WooCommerce shipping zones are canonical store configuration' ],
109 [ '/wc/v3/shipping_methods' , 'wc.data.shipping-method-types' , 'WooCommerce shipping method types are canonical store configuration' ],
110 ];
111
112 const BACKEND_METADATA_RULES = [
113 {
114 pattern: '/wc/v3/data/currencies*' ,
115 ruleId: 'wc.metadata.currencies' ,
116 reason: 'currency metadata may be needed to interpret WooCommerce records' ,
117 requiredPatterns: [ '/wc/v3/products*' , '/wc/v3/orders*' , '/wc/v3/coupons*' ],
118 },
119 {
120 pattern: '/wc/v3/data/countries*' ,
121 ruleId: 'wc.metadata.countries' ,
122 reason: 'country metadata may be needed to interpret WooCommerce tax and customer records' ,
123 requiredPatterns: [ '/wc/v3/customers*' , '/wc/v3/taxes*' , '/wc/v3/orders*' ],
124 },
125 ];
126
127 // Route -> extra query parameters the sampler must send to see the WHOLE collection.
128 //
129 // A WP REST collection route is free to apply a default filter when the caller sends no
130 // parameters, and several do. Discovery samples with `per_page` and nothing else, so it
131 // silently sees the filtered subset and the plan under-counts — the entity looks smaller
132 // than it is and the hidden records never reach the mapper. This is a CLASS of bug, not one
133 // route: any route with a defaulted status/type/scope filter has it.
134 //
135 // The fix is a table, not a special case in the sampler: add the route and the parameters
136 // that turn the filter off, with the reason. Kept next to the other route pattern tables
137 // (BACKEND_DATA_RULES et al.) because these are facts about core routes; patterns use the
138 // same `*` suffix matching, longest pattern wins, and a route with no entry gets `{}`.
139 const ROUTE_DEFAULT_QUERY_RULES = [
140 [
141 '/wc/v3/products/reviews*' ,
142 { status: 'all' },
143 'WooCommerce defaults product reviews to status=approved, hiding hold/spam/trash rows; observed live 2026-08-16 on the reference store as 114 of 120 reviews (6 on hold invisible to the plan)' ,
144 ],
145 ];
146
147 function defaultQueryRuleFor ( routePath ) {
148 const normalized = normalizeRoutePath (routePath);
149 return ROUTE_DEFAULT_QUERY_RULES
150 . filter (([ pattern ]) => routeMatchesPattern (normalized, pattern))
151 . sort (( a , b ) => b[ 0 ]. length - a[ 0 ]. length )[ 0 ] || null ;
152 }
153
154 // The query parameters to add when sampling `routePath`, or {} when the route has no
155 // documented default filter. Copied so a caller cannot mutate the rule table; callers merge
156 // their own paging parameters over the result.
157 function defaultQueryFor ( routePath ) {
158 const rule = defaultQueryRuleFor (routePath);
159 return rule ? { ... rule[ 1 ] } : {};
160 }
161
162 // The human-readable reason a default query applies, for the discovery note that tells a
163 // reader why this route was sampled with extra parameters. Null when none applies.
164 function defaultQueryReasonFor ( routePath ) {
165 const rule = defaultQueryRuleFor (routePath);
166 return rule ? rule[ 2 ] : null ;
167 }
168
169 // Plugin route rules are DATA, not code: they come from the declarative profiles under
170 // plugins/. Adding or correcting a plugin is a JSON edit plus a fixture, never a change
171 // here. Loaded once and memoized; tests inject rules directly via overrides.pluginRules.
172 let memoizedPluginRules = null ;
173
174 function defaultPluginRules () {
175 if (memoizedPluginRules) return memoizedPluginRules;
176 try {
177 // Required lazily so this module stays usable (with no plugin rules) if the profile
178 // directory is absent, e.g. a partial install.
179 const { pluginsRoot , buildRouteRules } = require ( './plugin-knowledge.js' );
180 memoizedPluginRules = buildRouteRules ( pluginsRoot ());
181 } catch (error) {
182 memoizedPluginRules = { dataRules: [], excludeRules: [], loadError: error.message };
183 }
184 return memoizedPluginRules;
185 }
186
187 function resetPluginRuleCache () {
188 memoizedPluginRules = null ;
189 }
190
191 const LEGACY_WC_PREFIXES = [
192 '/wc/v1/products' ,
193 '/wc/v1/coupons' ,
194 '/wc/v1/orders' ,
195 '/wc/v1/refunds' ,
196 '/wc/v1/customers' ,
197 '/wc/v1/taxes' ,
198 '/wc/v1/taxes/classes' ,
199 '/wc/v2/products' ,
200 '/wc/v2/coupons' ,
201 '/wc/v2/orders' ,
202 '/wc/v2/refunds' ,
203 '/wc/v2/customers' ,
204 '/wc/v2/taxes' ,
205 '/wc/v2/taxes/classes' ,
206 ];
207
208 function normalizeRoutePath ( routePath ) {
209 if ( typeof routePath !== 'string' || routePath. length === 0 ) {
210 return '/' ;
211 }
212 return routePath. startsWith ( '/' ) ? routePath : `/${ routePath }` ;
213 }
214
215 function routeMatchesPattern ( routePath , pattern ) {
216 const route = normalizeRoutePath (routePath);
217 if (pattern. endsWith ( '/*' )) {
218 const prefix = pattern. slice ( 0 , - 1 );
219 return route. startsWith (prefix);
220 }
221 if (pattern. endsWith ( '*' )) {
222 return route. startsWith (pattern. slice ( 0 , - 1 ));
223 }
224 return route === pattern;
225 }
226
227 // A WP REST index parameterized route looks like /wc/v3/orders/(?P<id>[\d]+)/notes.
228 // Normalizing every parameter segment to the same {parentId} placeholder a plugin-rest-child
229 // entity's `route` template uses lets that template be checked against the index without
230 // needing to know (or match) the parameter's name.
231 function normalizeIndexRouteTemplate ( routePath ) {
232 return routePath. replace ( / \(\? P< [ ^ >] + > [ ^ )] * \) / g , '{parentId}' );
233 }
234
235 // Whether the site's REST index advertises a route matching a plugin-rest-child entity's
236 // templated route (e.g. /wc/v3/orders/{parentId}/notes). This is a presence check only — it
237 // says the sub-resource exists on this site, not that any parent record actually has data
238 // there; that requires a live per-parent sample (see wp-discovery.js sampleChildEntities).
239 function childRouteAdvertised ( restIndexRoutes , template ) {
240 const routes = restIndexRoutes && typeof restIndexRoutes === 'object' ? Object. keys (restIndexRoutes) : [];
241 return routes. some (( route ) => normalizeIndexRouteTemplate (route) === template);
242 }
243
244 function findMatchingRule ( routePath , rules ) {
245 for ( const rule of rules) {
246 const [ pattern , ruleId , reason ] = rule;
247 if ( routeMatchesPattern (routePath, pattern)) {
248 return { ruleId, reason };
249 }
250 }
251 return null ;
252 }
253
254 function actionForCategory ( category ) {
255 if (category === 'backend_data' || category === 'public_commerce_data' ) {
256 return 'sample' ;
257 }
258 if (category === 'backend_metadata' ) {
259 return 'metadata' ;
260 }
261 return 'skip' ;
262 }
263
264 function makeClassification ( candidate , category , reason , ruleId , extra = {}) {
265 return {
266 routePath: candidate.routePath,
267 namespace: candidate.namespace,
268 category,
269 reason,
270 ruleId,
271 sampleByDefault: category === 'backend_data' ,
272 canIncludeByOverride: category !== 'backend_data' ,
273 includedByOverride: false ,
274 excludedByOverride: false ,
275 effectiveAction: actionForCategory (category),
276 ... extra,
277 };
278 }
279
280 function candidateHasCollectionShape ( candidate ) {
281 return Boolean (candidate.supportsPagination || candidate.getEndpoint?.args?.per_page || candidate.getEndpoint?.args?.page);
282 }
283
284 function hasSchema ( candidate ) {
285 return Boolean (candidate.getEndpoint?.schema || candidate.routeDefinition?.schema);
286 }
287
288 function classifyStoreCatalogRoute ( candidate , routeSet , options = {}) {
289 const routePath = candidate.routePath;
290 let canonicalRoute = null ;
291 let ruleId = null ;
292 let reason = null ;
293
294 if ( routeMatchesPattern (routePath, '/wc/store/v1/products/categories*' )) {
295 canonicalRoute = '/wc/v3/products/categories' ;
296 ruleId = 'wc.public-store.product-categories' ;
297 reason = 'WooCommerce Store API product categories expose durable public commerce taxonomy data' ;
298 } else if ( routeMatchesPattern (routePath, '/wc/store/v1/products*' )) {
299 canonicalRoute = '/wc/v3/products' ;
300 ruleId = 'wc.public-store.products' ;
301 reason = 'WooCommerce Store API products expose durable public commerce catalog data' ;
302 } else {
303 return null ;
304 }
305
306 const commerceMode = options.commerceMode || 'authenticated' ;
307 if (commerceMode !== 'public' && routeSet. has (canonicalRoute)) {
308 return makeClassification (
309 candidate,
310 'excluded_unsupported' ,
311 `duplicated by canonical ${ canonicalRoute } for authenticated/private commerce reads` ,
312 'wc.public-store.duplicate' ,
313 { duplicateOf: canonicalRoute },
314 );
315 }
316
317 return makeClassification (candidate, 'public_commerce_data' , reason, ruleId, {
318 canonicalRoute: routeSet. has (canonicalRoute) ? canonicalRoute : null ,
319 commerceMode,
320 });
321 }
322
323 function classifyLegacyWooCommerceRoute ( candidate , routeSet ) {
324 const routePath = candidate.routePath;
325 const legacyPrefix = LEGACY_WC_PREFIXES . find (( prefix ) => routeMatchesPattern (routePath, `${ prefix }*` ));
326 if ( ! legacyPrefix) {
327 return null ;
328 }
329
330 const canonicalRoute = routePath. replace ( / ^ \/ wc \/ v [12] \/ / , '/wc/v3/' );
331 if (routeSet. has (canonicalRoute)) {
332 return makeClassification (
333 candidate,
334 'excluded_unsupported' ,
335 `duplicated by canonical ${ canonicalRoute }` ,
336 'wc.duplicate.legacy-version' ,
337 { duplicateOf: canonicalRoute },
338 );
339 }
340
341 return makeClassification (
342 candidate,
343 'backend_data' ,
344 'legacy WooCommerce data route accepted because wc/v3 equivalent is unavailable' ,
345 'wc.data.legacy' ,
346 );
347 }
348
349 const EXCLUSION_RULE_GROUPS = [
350 [ 'excluded_frontend' , FRONTEND_RULES ],
351 [ 'excluded_template_editor' , TEMPLATE_EDITOR_RULES ],
352 [ 'excluded_runtime_session' , RUNTIME_RULES ],
353 [ 'excluded_admin_dashboard' , ADMIN_RULES ],
354 [ 'excluded_marketplace_setup' , MARKETPLACE_RULES ],
355 [ 'excluded_diagnostics' , DIAGNOSTIC_RULES ],
356 [ 'excluded_integration' , INTEGRATION_RULES ],
357 ];
358
359 function findExclusion ( routePath ) {
360 for ( const [ category , rules ] of EXCLUSION_RULE_GROUPS ) {
361 const rule = findMatchingRule (routePath, rules);
362 if (rule) return { category, ... rule };
363 }
364 return null ;
365 }
366
367 // Every route pattern owned by the classifier itself. Exported so the plugin knowledge
368 // validator can refuse a profile route that silently shadows a core rule.
369 function coreRulePatterns () {
370 const patterns = new Set ();
371 for ( const [, rules ] of EXCLUSION_RULE_GROUPS ) {
372 for ( const [ pattern ] of rules) patterns. add (pattern);
373 }
374 for ( const [ pattern ] of BACKEND_DATA_RULES ) patterns. add (pattern);
375 for ( const rule of BACKEND_METADATA_RULES ) patterns. add (rule.pattern);
376 return patterns;
377 }
378
379 function classifyBaseRoute ( candidate , routeSet , options = {}) {
380 const routePath = candidate.routePath;
381 const pluginRules = options.pluginRules || { dataRules: [], excludeRules: [] };
382
383 // Precedence: profile excludeRoutes > profile data routes > existing
384 // category rules > collection-shape fallback > unsupported.default. Profile data routes
385 // beat exclusion families because an explicitly listed route IS the per-capability
386 // opt-in; profiles cannot use a broad wildcard to reopen a family (validator enforces
387 // at least two concrete leading segments).
388 const pluginExclusion = findMatchingRule (routePath, pluginRules.excludeRules);
389 if (pluginExclusion) {
390 const existing = findExclusion (routePath);
391 return makeClassification (
392 candidate,
393 existing ? existing.category : 'excluded_unsupported' ,
394 pluginExclusion.reason,
395 pluginExclusion.ruleId,
396 );
397 }
398
399 const pluginData = findMatchingRule (routePath, pluginRules.dataRules);
400 if (pluginData) {
401 return makeClassification (candidate, 'backend_data' , pluginData.reason, pluginData.ruleId);
402 }
403
404 const exclusion = findExclusion (routePath);
405 if (exclusion) {
406 return makeClassification (candidate, exclusion.category, exclusion.reason, exclusion.ruleId);
407 }
408
409 const storeCatalogClassification = classifyStoreCatalogRoute (candidate, routeSet, options);
410 if (storeCatalogClassification) {
411 return storeCatalogClassification;
412 }
413
414 const legacyWooCommerce = classifyLegacyWooCommerceRoute (candidate, routeSet);
415 if (legacyWooCommerce) {
416 return legacyWooCommerce;
417 }
418
419 const dataRule = findMatchingRule (routePath, BACKEND_DATA_RULES );
420 if (dataRule) {
421 return makeClassification (candidate, 'backend_data' , dataRule.reason, dataRule.ruleId);
422 }
423
424 const metadataRule = BACKEND_METADATA_RULES . find (( rule ) => routeMatchesPattern (routePath, rule.pattern));
425 if (metadataRule) {
426 return makeClassification (candidate, 'backend_metadata' , metadataRule.reason, metadataRule.ruleId, {
427 requiredPatterns: metadataRule.requiredPatterns,
428 });
429 }
430
431 // Registered non-core post types and taxonomies are persisted-record collections by
432 // definition, so /wp/v2/types + /wp/v2/taxonomies are positive evidence rather than a
433 // heuristic. This is the rule that makes custom post types discoverable at all:
434 // VERIFIED LIVE 2026-07-30 that the WordPress REST index advertises `schema` on ZERO
435 // routes (0 of 957 on a real site), so the collection-shape fallbacks below — which
436 // require hasSchema() — never fire in production. Without this rule a plugin CPT is
437 // invisible unless someone hardcodes its route.
438 const registered = options.registeredRestBases instanceof Map
439 ? options.registeredRestBases. get (routePath)
440 : null ;
441 if (registered) {
442 return makeClassification (
443 candidate,
444 'backend_data' ,
445 `${ registered . kind === 'taxonomy' ? 'taxonomy' : 'post type'} "${ registered . slug }" is registered and REST-visible, so its collection holds persisted records` ,
446 'wp.data.registered-type' ,
447 { registeredType: registered.slug, registeredKind: registered.kind, hierarchical: registered.hierarchical === true },
448 );
449 }
450
451 if (candidate.namespace === 'wp/v2' && candidateHasCollectionShape (candidate) && hasSchema (candidate)) {
452 return makeClassification (
453 candidate,
454 'backend_data' ,
455 'wp/v2 collection route has persisted-record shape and is not otherwise excluded' ,
456 'wp.data.custom-collection' ,
457 );
458 }
459
460 if ( candidateHasCollectionShape (candidate) && hasSchema (candidate)) {
461 return makeClassification (
462 candidate,
463 'backend_data' ,
464 'collection route has persisted-record REST shape and is not otherwise excluded' ,
465 'plugin.data.collection-shape' ,
466 );
467 }
468
469 return makeClassification (
470 candidate,
471 'excluded_unsupported' ,
472 'route is not in the backend data allowlist and does not have an accepted collection shape' ,
473 'unsupported.default' ,
474 );
475 }
476
477 function normalizeList ( values ) {
478 if ( ! Array. isArray (values)) {
479 return [];
480 }
481 return values. filter (Boolean). map (String);
482 }
483
484 function normalizeOverrides ( overrides = {}) {
485 return {
486 includeRoutes: normalizeList (overrides.includeRoutes). map (normalizeRoutePath),
487 includeNamespaces: normalizeList (overrides.includeNamespaces),
488 includeExcludedCategories: normalizeList (overrides.includeExcludedCategories),
489 excludeRoutes: normalizeList (overrides.excludeRoutes). map (normalizeRoutePath),
490 overrideReason: overrides.overrideReason ? String (overrides.overrideReason) : null ,
491 commerceMode: overrides.commerceMode === 'public' ? 'public' : 'authenticated' ,
492 // Tests and callers may inject rules; otherwise the checked-in profiles are used.
493 pluginRules: overrides.pluginRules || defaultPluginRules (),
494 registeredRestBases: overrides.registeredRestBases instanceof Map ? overrides.registeredRestBases : null ,
495 };
496 }
497
498 // Build the route -> registered-type lookup consumed by the wp.data.registered-type rule.
499 // Core types/taxonomies are excluded: they already have explicit allowlist rules, or are
500 // deliberately excluded site-composition surfaces.
501 const CORE_POST_TYPE_SLUGS = new Set ([
502 'post' , 'page' , 'attachment' , 'nav_menu_item' , 'wp_block' , 'wp_template' ,
503 'wp_template_part' , 'wp_navigation' , 'wp_font_family' , 'wp_font_face' , 'wp_global_styles' ,
504 'product' , 'product_variation' , 'shop_order' , 'shop_order_refund' , 'shop_coupon' ,
505 ]);
506 const CORE_TAXONOMY_SLUGS = new Set ([
507 'category' , 'post_tag' , 'nav_menu' , 'link_category' , 'post_format' , 'wp_pattern_category' ,
508 'product_cat' , 'product_tag' , 'product_brand' , 'product_shipping_class' , 'product_type' , 'product_visibility' ,
509 ]);
510
511 function buildRegisteredRestBases ({ types = null , taxonomies = null } = {}) {
512 const map = new Map ();
513 const add = ( kind , slug , entry , coreSlugs ) => {
514 if ( ! entry || coreSlugs. has (slug)) return ;
515 if (entry.show_in_rest === false ) return ;
516 const restBase = entry.rest_base || entry.slug || slug;
517 if ( ! restBase) return ;
518 const namespace = entry.rest_namespace || 'wp/v2' ;
519 map. set ( `/${ namespace }/${ restBase }` , {
520 kind,
521 slug,
522 restBase,
523 hierarchical: entry.hierarchical === true ,
524 attachedToTypes: Array. isArray (entry.types) ? [ ... entry.types] : [],
525 });
526 };
527 for ( const [ slug , entry ] of Object. entries (types || {})) add ( 'post-type' , slug, entry, CORE_POST_TYPE_SLUGS );
528 for ( const [ slug , entry ] of Object. entries (taxonomies || {})) add ( 'taxonomy' , slug, entry, CORE_TAXONOMY_SLUGS );
529 return map;
530 }
531
532 function isIncludedCategory ( category , includeExcludedCategories ) {
533 return includeExcludedCategories. includes (category) || includeExcludedCategories. includes ( 'all' );
534 }
535
536 function finalizeMetadataActions ( classifications ) {
537 const sampledRoutes = classifications
538 . filter (( classification ) => classification.effectiveAction === 'sample' )
539 . map (( classification ) => classification.routePath);
540
541 return classifications. map (( classification ) => {
542 if (classification.category !== 'backend_metadata' || classification.includedByOverride || classification.excludedByOverride) {
543 return classification;
544 }
545
546 const requiredPatterns = Array. isArray (classification.requiredPatterns) ? classification.requiredPatterns : [];
547 const hasRelatedSampledRoute = requiredPatterns. some (( pattern ) =>
548 sampledRoutes. some (( routePath ) => routeMatchesPattern (routePath, pattern)));
549
550 if (hasRelatedSampledRoute) {
551 return { ... classification, effectiveAction: 'metadata' };
552 }
553
554 return {
555 ... classification,
556 effectiveAction: 'skip' ,
557 sampleByDefault: false ,
558 reason: `${ classification . reason }; no related sampled backend route is in scope` ,
559 };
560 });
561 }
562
563 function classifyRoutes ( candidates , overrides = {}) {
564 const normalizedOverrides = normalizeOverrides (overrides);
565 const routeSet = new Set (candidates. map (( candidate ) => candidate.routePath));
566 let classifications = candidates. map (( candidate ) => {
567 const classification = classifyBaseRoute (candidate, routeSet, normalizedOverrides);
568
569 if (normalizedOverrides.excludeRoutes. includes (candidate.routePath)) {
570 return {
571 ... classification,
572 excludedByOverride: true ,
573 includedByOverride: false ,
574 effectiveAction: 'skip' ,
575 sampleByDefault: false ,
576 overrideReason: normalizedOverrides.overrideReason,
577 };
578 }
579
580 const includeRoute = normalizedOverrides.includeRoutes. includes (candidate.routePath);
581 const includeCategory = isIncludedCategory (classification.category, normalizedOverrides.includeExcludedCategories);
582 if (includeRoute || includeCategory) {
583 return {
584 ... classification,
585 includedByOverride: true ,
586 effectiveAction: 'sample' ,
587 overrideReason: normalizedOverrides.overrideReason,
588 };
589 }
590
591 return classification;
592 });
593
594 classifications = finalizeMetadataActions (classifications);
595 return classifications. sort (( a , b ) => a.routePath. localeCompare (b.routePath));
596 }
597
598 function summarizeSkippedByCategory ( classifications ) {
599 const summary = {};
600 for ( const classification of classifications) {
601 if (classification.effectiveAction !== 'skip' ) {
602 continue ;
603 }
604 summary[classification.category] = (summary[classification.category] || 0 ) + 1 ;
605 }
606 return Object. fromEntries (Object. entries (summary). sort (([ a ], [ b ]) => a. localeCompare (b)));
607 }
608
609 module . exports = {
610 ROUTE_CATEGORIES,
611 classifyRoutes,
612 normalizeOverrides,
613 routeMatchesPattern,
614 childRouteAdvertised,
615 summarizeSkippedByCategory,
616 coreRulePatterns,
617 ROUTE_DEFAULT_QUERY_RULES,
618 defaultQueryFor,
619 defaultQueryReasonFor,
620 defaultPluginRules,
621 resetPluginRuleCache,
622 buildRegisteredRestBases,
623 CORE_POST_TYPE_SLUGS,
624 CORE_TAXONOMY_SLUGS,
625 };