Setting the file. One moment.
Wp Discovery · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 471
function markdownJsonBlock
— line 471
This file
Number 18.1
Position 1 of 4
Type JavaScript
Size 28 KB
Lines 774 scripts/ wp-discovery.js
JavaScript · 774 lines · 28 KB
9
// the exact same discipline instead of re-deriving it.
10 const {
11 DEFAULT_TIMEOUT_MS ,
12 DEFAULT_RATE_LIMIT_RPM ,
13 DEFAULT_MAX_RETRIES ,
14 configureRateLimit ,
15 buildHeaders ,
16 normalizeBaseUrl ,
17 fetchJson ,
18 parseTotalHeader ,
19 } = require ( '../lib/wp-http.js' );
20 const { classifyRoutes , summarizeSkippedByCategory } = require ( '../lib/wp-route-classifier.js' );
21
22 const DEFAULT_SAMPLE_LIMIT = 3 ;
23 let progress;
24
25 function printUsage () {
26 console. log ( `Usage:
27 node wp-discovery.js --base-url <url> --out-dir <dir> [auth options]
28
29 Required:
30 --base-url <url> WordPress site base URL, e.g. https://example.com
31 --out-dir <dir> Directory to write discovery markdown files into
32
33 Authentication options:
34 --username <name> WordPress username for Application Password auth
35 --application-password <pw> WordPress Application Password
36 --api-key <token> API key/token for custom auth setups
37 --api-key-header <name> Header name for --api-key. Defaults to Authorization
38 --auth-header <'Name: Value'> Add a raw HTTP header. Can be repeated.
39
40 Optional:
41 --sample-limit <n> Number of sample records per entity. Default: 3
42 --timeout-ms <n> Request timeout in ms. Default: 60000
43 --rate-limit-rpm <n> Max requests per minute. Default: 120
44 --max-retries <n> Retries on 429/503 (honors Retry-After). Default: 3
45 --commerce-mode <mode> WooCommerce read mode: public | authenticated.
46 Defaults to public without auth, otherwise authenticated.
47 --include-namespace <ns> Only inspect a namespace. Can be repeated.
48 --include-route <path> Force-sample a route. Can be repeated.
49 --include-excluded-category <category>
50 Force-sample an excluded category. Can be repeated.
51 --exclude-route <path> Skip a route even if otherwise sampled. Can be repeated.
52 --override-reason <text> Reason recorded for include/exclude overrides.
53 --progress-log <path> Append progress NDJSON records to this file.
54 --help Show this help text
55
56 Examples:
57 node wp-discovery.js \
58 --base-url https://example.com \
59 --out-dir migrations/acme/data/wp-discovery \
60 --username admin \
61 --application-password 'abcd efgh ijkl mnop'
62
63 node wp-discovery.js \
64 --base-url https://example.com \
65 --out-dir migrations/acme/data/wp-discovery \
66 --api-key $WP_API_KEY \
67 --api-key-header X-API-Key
68 ` );
69 }
70
71 function parseArgs ( argv ) {
72 const args = {
73 authHeaders: [],
74 includeNamespaces: [],
75 includeRoutes: [],
76 includeExcludedCategories: [],
77 excludeRoutes: [],
78 overrideReason: null ,
79 sampleLimit: DEFAULT_SAMPLE_LIMIT ,
80 timeoutMs: DEFAULT_TIMEOUT_MS ,
81 rateLimitRpm: DEFAULT_RATE_LIMIT_RPM ,
82 maxRetries: DEFAULT_MAX_RETRIES ,
83 commerceMode: null ,
84 };
85
86 for ( let i = 0 ; i < argv. length ; i += 1 ) {
87 const arg = argv[i];
88 const next = argv[i + 1 ];
89
90 switch (arg) {
91 case '--help' :
92 case '-h' :
93 args.help = true ;
94 break ;
95 case '--base-url' :
96 args.baseUrl = next;
97 i += 1 ;
98 break ;
99 case '--out-dir' :
100 args.outDir = next;
101 i += 1 ;
102 break ;
103 case '--username' :
104 args.username = next;
105 i += 1 ;
106 break ;
107 case '--application-password' :
108 args.applicationPassword = next;
109 i += 1 ;
110 break ;
111 case '--api-key' :
112 args.apiKey = next;
113 i += 1 ;
114 break ;
115 case '--api-key-header' :
116 args.apiKeyHeader = next;
117 i += 1 ;
118 break ;
119 case '--auth-header' :
120 args.authHeaders. push (next);
121 i += 1 ;
122 break ;
123 case '--sample-limit' :
124 args.sampleLimit = Number. parseInt (next, 10 );
125 i += 1 ;
126 break ;
127 case '--timeout-ms' :
128 args.timeoutMs = Number. parseInt (next, 10 );
129 i += 1 ;
130 break ;
131 case '--rate-limit-rpm' :
132 args.rateLimitRpm = Number. parseInt (next, 10 );
133 i += 1 ;
134 break ;
135 case '--max-retries' :
136 args.maxRetries = Number. parseInt (next, 10 );
137 i += 1 ;
138 break ;
139 case '--commerce-mode' :
140 args.commerceMode = next;
141 i += 1 ;
142 break ;
143 case '--include-namespace' :
144 args.includeNamespaces. push (next);
145 i += 1 ;
146 break ;
147 case '--include-route' :
148 args.includeRoutes. push (next);
149 i += 1 ;
150 break ;
151 case '--include-excluded-category' :
152 args.includeExcludedCategories. push (next);
153 i += 1 ;
154 break ;
155 case '--exclude-route' :
156 args.excludeRoutes. push (next);
157 i += 1 ;
158 break ;
159 case '--override-reason' :
160 args.overrideReason = next;
161 i += 1 ;
162 break ;
163 default :
164 if (arg. startsWith ( '--' )) {
165 throw new Error ( `Unknown argument: ${ arg }` );
166 }
167 }
168 }
169
170 if ( ! args.baseUrl) {
171 args.baseUrl = process.env. WP_BASE_URL || process.env. WP_SITE_URL ;
172 }
173 if ( ! args.outDir) {
174 args.outDir = process.env. WP_DISCOVERY_OUT_DIR ;
175 }
176 if ( ! args.apiKey) {
177 args.apiKey = process.env. WP_API_KEY ;
178 }
179 if ( ! args.apiKeyHeader) {
180 args.apiKeyHeader = process.env. WP_API_KEY_HEADER ;
181 }
182 if ( ! args.username) {
183 args.username = process.env. WP_USERNAME ;
184 }
185 if ( ! args.applicationPassword) {
186 args.applicationPassword = process.env. WP_APPLICATION_PASSWORD ;
187 }
188 if (args.authHeaders. length === 0 && process.env. WP_AUTH_HEADER ) {
189 args.authHeaders. push (process.env. WP_AUTH_HEADER );
190 }
191
192 if ( ! Number. isFinite (args.sampleLimit) || args.sampleLimit < 1 ) {
193 args.sampleLimit = DEFAULT_SAMPLE_LIMIT ;
194 }
195 if ( ! Number. isFinite (args.timeoutMs) || args.timeoutMs < 1000 ) {
196 args.timeoutMs = DEFAULT_TIMEOUT_MS ;
197 }
198 if ( ! Number. isFinite (args.rateLimitRpm) || args.rateLimitRpm < 1 ) {
199 args.rateLimitRpm = DEFAULT_RATE_LIMIT_RPM ;
200 }
201 if ( ! Number. isFinite (args.maxRetries) || args.maxRetries < 0 ) {
202 args.maxRetries = DEFAULT_MAX_RETRIES ;
203 }
204 if (args.commerceMode !== 'public' && args.commerceMode !== 'authenticated' ) {
205 args.commerceMode = (args.username && args.applicationPassword) || args.apiKey || args.authHeaders. length > 0
206 ? 'authenticated'
207 : 'public' ;
208 }
209
210 return args;
211 }
212
213 function endpointMethods ( endpoint ) {
214 const raw = endpoint?.methods;
215 if (Array. isArray (raw)) {
216 return raw. map (String);
217 }
218 if ( typeof raw === 'string' ) {
219 return raw. split ( ',' ). map (( value ) => value. trim ()). filter (Boolean);
220 }
221 if (raw && typeof raw === 'object' ) {
222 return Object. keys (raw);
223 }
224 return [];
225 }
226
227 function routeSegments ( routePath ) {
228 return routePath. split ( '/' ). filter (Boolean);
229 }
230
231 function summarizeRelationships ( record ) {
232 const links = record?._links;
233 if ( ! links || typeof links !== 'object' ) {
234 return [];
235 }
236 // HAL housekeeping rels carry no entity relationship signal.
237 const ignored = new Set ([ 'self' , 'collection' , 'about' , 'curies' ]);
238 const relationships = [];
239
240 for ( const [ rel , entries ] of Object. entries (links)) {
241 if (ignored. has (rel)) {
242 continue ;
243 }
244 const list = Array. isArray (entries) ? entries : [entries];
245 const hrefs = list. map (( entry ) => entry?.href). filter (Boolean);
246 if (hrefs. length === 0 ) {
247 continue ;
248 }
249 relationships. push ({
250 rel,
251 embeddable: list. some (( entry ) => entry?.embeddable === true ),
252 hrefs,
253 });
254 }
255
256 return relationships;
257 }
258
259 function isParameterizedRoute ( routePath ) {
260 return routePath. includes ( '(?P<' );
261 }
262
263 function slugify ( value ) {
264 return value
265 . toLowerCase ()
266 . replace ( / [ ^ a-z0-9] + / g , '-' )
267 . replace ( / ^ - +| - +$ / g , '' ) || 'entity' ;
268 }
269
270 function summarizeAuthMode ( args ) {
271 if (args.username && args.applicationPassword) {
272 return 'basic-application-password' ;
273 }
274 if (args.apiKey) {
275 return `api-key:${ args . apiKeyHeader || 'Authorization'}` ;
276 }
277 if (args.authHeaders. length > 0 ) {
278 return 'custom-header' ;
279 }
280 return 'none' ;
281 }
282
283 function summarizeOverrides ( args ) {
284 return {
285 includeRoutes: args.includeRoutes,
286 includeNamespaces: args.includeNamespaces,
287 includeExcludedCategories: args.includeExcludedCategories,
288 excludeRoutes: args.excludeRoutes,
289 overrideReason: args.overrideReason,
290 commerceMode: args.commerceMode,
291 };
292 }
293
294 function deriveEntityCandidates ( indexJson , includeNamespaces ) {
295 const routes = indexJson?.routes || {};
296 const candidates = [];
297
298 for ( const [ routePath , routeDefinition ] of Object. entries (routes)) {
299 if ( ! routePath. startsWith ( '/' ) || routePath === '/' || isParameterizedRoute (routePath)) {
300 continue ;
301 }
302
303 const segments = routeSegments (routePath);
304 if (segments. length < 2 ) {
305 continue ;
306 }
307
308 const namespace = segments. slice ( 0 , 2 ). join ( '/' );
309 if (includeNamespaces. length > 0 && ! includeNamespaces. includes (namespace)) {
310 continue ;
311 }
312
313 const endpoints = Array. isArray (routeDefinition?.endpoints) ? routeDefinition.endpoints : [];
314 const getEndpoint = endpoints. find (( endpoint ) => endpointMethods (endpoint). includes ( 'GET' ));
315 if ( ! getEndpoint) {
316 continue ;
317 }
318
319 const entityName = segments[segments. length - 1 ];
320 const supportsPagination = Boolean (getEndpoint?.args?.page || getEndpoint?.args?.per_page);
321 const hasSchema = Boolean (getEndpoint?.schema || routeDefinition?.schema);
322
323 if ( ! hasSchema && ! supportsPagination && segments. length < 3 ) {
324 continue ;
325 }
326
327 // Derive the file name from the full path after the namespace so distinct
328 // routes that share a last segment (e.g. /wp/v2/categories vs.
329 // /wp/v2/block-patterns/categories) do not collide onto one file.
330 const pathSlug = slugify (segments. slice ( 2 ). join ( '-' )) || slugify (entityName);
331
332 candidates. push ({
333 entityName,
334 namespace,
335 routePath,
336 endpoints,
337 getEndpoint,
338 routeDefinition,
339 supportsPagination,
340 fileName: `${ slugify ( namespace ) }--${ pathSlug }.md` ,
341 });
342 }
343
344 const unique = new Map ();
345 for ( const candidate of candidates) {
346 const key = `${ candidate . namespace }:${ candidate . routePath }` ;
347 if ( ! unique. has (key)) {
348 unique. set (key, candidate);
349 }
350 }
351
352 return [ ... unique. values ()]. sort (( a , b ) => a.routePath. localeCompare (b.routePath));
353 }
354
355 async function inspectEntity ( baseUrl , headers , candidate , options ) {
356 const details = {
357 entityName: candidate.entityName,
358 namespace: candidate.namespace,
359 routePath: candidate.routePath,
360 fileName: candidate.fileName,
361 classification: candidate.classification || null ,
362 methods: [ ...new Set (candidate.endpoints. flatMap (endpointMethods))]. sort (),
363 supportsPagination: candidate.supportsPagination,
364 discoveryNotes: [],
365 requestErrors: [],
366 collectionArgs: candidate.getEndpoint?.args || null ,
367 schema: candidate.getEndpoint?.schema || candidate.routeDefinition?.schema || null ,
368 optionsSchema: null ,
369 sampleRecords: [],
370 sampleRecordCount: 0 ,
371 recordCount: null ,
372 inUse: null ,
373 relationships: [],
374 responseShape: 'unknown' ,
375 };
376
377 const optionsResponse = await fetchJson (baseUrl, candidate.routePath, {
378 headers,
379 method: 'OPTIONS' ,
380 timeoutMs: options.timeoutMs,
381 progress: options.progress,
382 progressContext: {
383 step: 'inspect-endpoint' ,
384 entity: candidate.routePath,
385 },
386 });
387
388 if (optionsResponse.ok && optionsResponse.json) {
389 const optionEndpoints = Array. isArray (optionsResponse.json?.endpoints) ? optionsResponse.json.endpoints : [];
390 const getEndpoint = optionEndpoints. find (( endpoint ) => endpointMethods (endpoint). includes ( 'GET' ));
391 if (getEndpoint?.args) {
392 details.collectionArgs = getEndpoint.args;
393 }
394 if (optionsResponse.json?.schema) {
395 details.optionsSchema = optionsResponse.json.schema;
396 details.schema = optionsResponse.json.schema;
397 } else if (getEndpoint?.schema) {
398 details.optionsSchema = getEndpoint.schema;
399 details.schema = getEndpoint.schema;
400 }
401 } else if (optionsResponse.status !== 404 && optionsResponse.status !== 405 ) {
402 details.requestErrors. push ({
403 request: 'OPTIONS' ,
404 routePath: candidate.routePath,
405 status: optionsResponse.status,
406 statusText: optionsResponse.statusText,
407 url: optionsResponse.url,
408 });
409 }
410
411 const query = candidate.supportsPagination ? { per_page: options.sampleLimit } : {};
412 const sampleResponse = await fetchJson (baseUrl, candidate.routePath, {
413 headers,
414 method: 'GET' ,
415 query,
416 timeoutMs: options.timeoutMs,
417 progress: options.progress,
418 progressContext: {
419 step: 'inspect-endpoint' ,
420 entity: candidate.routePath,
421 },
422 });
423
424 if (sampleResponse.ok) {
425 const payload = sampleResponse.json;
426 const totalFromHeader = parseTotalHeader (sampleResponse.headers);
427 if (Array. isArray (payload)) {
428 details.responseShape = 'array' ;
429 details.sampleRecords = payload. slice ( 0 , options.sampleLimit);
430 details.sampleRecordCount = payload. length ;
431 details.recordCount = totalFromHeader !== null ? totalFromHeader : payload. length ;
432 details.inUse = details.recordCount > 0 ;
433 if (totalFromHeader === null ) {
434 details.discoveryNotes. push ( 'No X-WP-Total header returned; recordCount reflects only the sampled page and may undercount the true total.' );
435 }
436 if (candidate.routePath. startsWith ( '/wc/store/v1/' ) && ! Object. keys (sampleResponse.headers || {}). some (( key ) => key. toLowerCase () === 'x-wp-totalpages' )) {
437 details.discoveryNotes. push ( 'WooCommerce Store API route does not advertise X-WP-TotalPages; generated readers must stop on a short page when bulk-extracting this entity.' );
438 }
439 if (payload. length === 0 ) {
440 details.discoveryNotes. push ( 'Endpoint returned an empty array. The entity is advertised but appears unused (no records).' );
441 }
442 } else if (payload && typeof payload === 'object' ) {
443 details.responseShape = 'object' ;
444 details.sampleRecords = [payload];
445 details.sampleRecordCount = 1 ;
446 details.recordCount = totalFromHeader !== null ? totalFromHeader : 1 ;
447 details.inUse = details.recordCount > 0 ;
448 } else {
449 details.responseShape = typeof payload;
450 details.discoveryNotes. push ( `Endpoint returned a non-object payload of type ${ typeof payload }.` );
451 }
452
453 const firstRecord = details.sampleRecords[ 0 ];
454 if (firstRecord && typeof firstRecord === 'object' ) {
455 details.relationships = summarizeRelationships (firstRecord);
456 }
457 } else {
458 details.requestErrors. push ({
459 request: 'GET' ,
460 routePath: candidate.routePath,
461 status: sampleResponse.status,
462 statusText: sampleResponse.statusText,
463 url: sampleResponse.url,
464 body: sampleResponse.text ? sampleResponse.text. slice ( 0 , 1000 ) : '' ,
465 });
466 }
467
468 return details;
469 }
470
471 function markdownJsonBlock ( value ) {
472 if (value === null || value === undefined ) {
473 return ' \n\n `Unavailable`' ;
474 }
475
476 return ` \n\n\`\`\` json \n ${ JSON . stringify ( value , null , 2 ) } \n\`\`\` ` ;
477 }
478
479 function renderEntityFile ( details ) {
480 const errorLines = details.requestErrors. length > 0
481 ? details.requestErrors. map (( error ) => `- ${ error . request } ${ error . routePath }: ${ error . status } ${ error . statusText }` ). join ( ' \n ' )
482 : '- None' ;
483
484 const notes = details.discoveryNotes. length > 0
485 ? details.discoveryNotes. map (( note ) => `- ${ note }` ). join ( ' \n ' )
486 : '- None' ;
487
488 const relationshipLines = details.relationships. length > 0
489 ? details.relationships
490 . map (( relationship ) => `- \` ${ relationship . rel } \` ${ relationship . embeddable ? ' (embeddable)' : ''} → ${ relationship . hrefs . join ( ', ' ) }` )
491 . join ( ' \n ' )
492 : '- None detected (no `_links` in sample record)' ;
493
494 const recordCountLabel = details.recordCount === null ? 'unknown' : String (details.recordCount);
495 const inUseLabel = details.inUse === null ? 'unknown' : details.inUse ? 'yes' : 'no (advertised but empty)' ;
496 const classificationLines = details.classification
497 ? `- Discovery category: \` ${ details . classification . category } \`\n ` +
498 `- Discovery rule: \` ${ details . classification . ruleId } \`\n ` +
499 `- Discovery reason: ${ details . classification . reason } \n ` +
500 `- Included by override: \` ${ details . classification . includedByOverride ? 'yes' : 'no'} \`\n ` +
501 `- Excluded by override: \` ${ details . classification . excludedByOverride ? 'yes' : 'no'} \`\n `
502 : '' ;
503
504 return `# ${ details . entityName } \n\n ` +
505 `- Namespace: \` ${ details . namespace } \`\n ` +
506 `- Route: \` ${ details . routePath } \`\n ` +
507 classificationLines +
508 `- Methods: ${ details . methods . map (( method ) => ` \` ${ method } \` ` ). join ( ', ' ) || '`unknown`'} \n ` +
509 `- Response shape: \` ${ details . responseShape } \`\n ` +
510 `- Record count: \` ${ recordCountLabel } \`\n ` +
511 `- In use: \` ${ inUseLabel } \`\n ` +
512 `- Sample records captured: ${ details . sampleRecords . length } \n\n ` +
513 `## Notes \n ${ notes } \n\n ` +
514 `## Relationships \n ${ relationshipLines } \n\n ` +
515 `## Request Errors \n ${ errorLines } \n\n ` +
516 `## Schema${ markdownJsonBlock ( details . schema ) } \n\n ` +
517 `## Collection Args${ markdownJsonBlock ( details . collectionArgs ) } \n\n ` +
518 `## Sample Records${ markdownJsonBlock ( details . sampleRecords ) } \n ` ;
519 }
520
521 function renderIndexFile ( context ) {
522 const entityRows = context.entities. map (( entity ) => {
523 const status = entity.requestErrors. length > 0 ? 'partial' : 'ok' ;
524 const recordCountLabel = entity.recordCount === null ? '?' : String (entity.recordCount);
525 const inUseLabel = entity.inUse === null ? '?' : entity.inUse ? 'yes' : 'no' ;
526 return `| ${ entity . entityName } | \` ${ entity . namespace } \` | \` ${ entity . routePath } \` | ${ recordCountLabel } | ${ inUseLabel } | ${ entity . sampleRecords . length } | ${ status } | [${ entity . fileName }](./${ entity . fileName }) |` ;
527 }). join ( ' \n ' );
528
529 const errorLines = context.entities
530 . flatMap (( entity ) => entity.requestErrors. map (( error ) => `- ${ entity . entityName }: ${ error . request } ${ error . routePath } -> ${ error . status } ${ error . statusText }` ));
531 const skippedByCategoryLines = Object. entries (context.skippedByCategory || {})
532 . map (([ category , count ]) => `- \` ${ category } \` : ${ count }` )
533 . join ( ' \n ' ) || '- None' ;
534 const overrideLines = [
535 `- Include routes: ${ context . overrides . includeRoutes . map (( route ) => ` \` ${ route } \` ` ). join ( ', ' ) || '`none`'}` ,
536 `- Include namespaces: ${ context . overrides . includeNamespaces . map (( namespace ) => ` \` ${ namespace } \` ` ). join ( ', ' ) || '`none`'}` ,
537 `- Include excluded categories: ${ context . overrides . includeExcludedCategories . map (( category ) => ` \` ${ category } \` ` ). join ( ', ' ) || '`none`'}` ,
538 `- Exclude routes: ${ context . overrides . excludeRoutes . map (( route ) => ` \` ${ route } \` ` ). join ( ', ' ) || '`none`'}` ,
539 `- Override reason: ${ context . overrides . overrideReason ? context . overrides . overrideReason : '`none`'}` ,
540 ]. join ( ' \n ' );
541
542 const authGatedEntities = context.entities. filter (( entity ) =>
543 entity.requestErrors. some (( error ) => error.status === 401 || error.status === 403 ));
544 const authWarning = authGatedEntities. length > 0
545 ? `## ⚠️ Incomplete Capture (Authentication) \n\n ` +
546 `${ authGatedEntities . length } entit${ authGatedEntities . length === 1 ? 'y' : 'ies'} returned 401/403 and ` +
547 `could not be captured` +
548 `${ context . authMode === 'none' ? ' — this run used **no credentials**' : ` with auth mode \` ${ context . authMode } \` (insufficient scope)`}. \n ` +
549 `Their \` recordCount \` / \` inUse \` are unreliable: an auth-gated entity can look empty or errored even when the site uses it heavily (e.g. WooCommerce orders/customers, drafts, private fields). \n ` +
550 `Re-run with credentials for a complete and trustworthy capture. \n\n ` +
551 `Auth-gated entities: ${ authGatedEntities . map (( entity ) => ` \` ${ entity . entityName } \` ` ). join ( ', ' ) } \n\n `
552 : '' ;
553
554 return `# WordPress Discovery \n\n ` +
555 `- Generated at: \` ${ context . generatedAt } \`\n ` +
556 `- Base URL: \` ${ context . baseUrl } \`\n ` +
557 `- REST root: \` ${ context . restRoot } \`\n ` +
558 `- Auth mode: \` ${ context . authMode } \`\n ` +
559 `- Advertised auth providers: ${ context . authProviders . map (( provider ) => ` \` ${ provider } \` ` ). join ( ', ' ) || '`none`'} \n ` +
560 `- Namespaces advertised: ${ context . namespaces . map (( namespace ) => ` \` ${ namespace } \` ` ). join ( ', ' ) || '`none`'} \n ` +
561 `- Advertised routes: ${ context . totalAdvertisedRoutes } \n ` +
562 `- Candidate routes after generic filtering: ${ context . totalCandidateRoutes } \n ` +
563 `- Sampled backend data/metadata routes: ${ context . sampledRoutes } \n ` +
564 `- Skipped routes: ${ context . skippedRoutes } \n ` +
565 `- Entities documented: ${ context . entities . length } \n\n ` +
566 authWarning +
567 `## Route Scope \n\n ` +
568 `### Skipped Routes by Category \n\n ${ skippedByCategoryLines } \n\n ` +
569 `### Overrides \n\n ${ overrideLines } \n\n ` +
570 `Skipped route evidence: [skipped-routes.json](./skipped-routes.json) \n\n ` +
571 `## Discovery Summary \n\n ` +
572 `| Entity | Namespace | Route | Records | In use | Samples | Status | File | \n ` +
573 `| --- | --- | --- | ---: | --- | ---: | --- | --- | \n ` +
574 `${ entityRows || '| None | - | - | - | - | 0 | - | - |'} \n\n ` +
575 `## Route Index Sample${ markdownJsonBlock ( context . routeIndexSample ) } \n\n ` +
576 `## Errors \n ${ errorLines . length > 0 ? errorLines . join ( ' \n ' ) : '- None'} \n ` ;
577 }
578
579 function skippedRoutesPayload ( context ) {
580 return {
581 generatedAt: context.generatedAt,
582 totalAdvertisedRoutes: context.totalAdvertisedRoutes,
583 totalCandidateRoutes: context.totalCandidateRoutes,
584 sampledRoutes: context.sampledRoutes,
585 skippedRoutes: context.skippedRoutes,
586 overrides: context.overrides,
587 routes: context.classifications
588 . filter (( classification ) => classification.effectiveAction === 'skip' )
589 . map (( classification ) => ({
590 routePath: classification.routePath,
591 namespace: classification.namespace,
592 category: classification.category,
593 reason: classification.reason,
594 ruleId: classification.ruleId,
595 sampleByDefault: classification.sampleByDefault,
596 canIncludeByOverride: classification.canIncludeByOverride,
597 includedByOverride: classification.includedByOverride,
598 excludedByOverride: classification.excludedByOverride,
599 effectiveAction: classification.effectiveAction,
600 duplicateOf: classification.duplicateOf,
601 overrideReason: classification.overrideReason,
602 })),
603 };
604 }
605
606 async function ensureDir ( dirPath ) {
607 await fs. mkdir (dirPath, { recursive: true });
608 }
609
610 async function writeOutputs ( outDir , context , progress ) {
611 await ensureDir (outDir);
612 await fs. writeFile (path. join (outDir, 'README.md' ), renderIndexFile (context), 'utf8' );
613 progress?. progress ( 'Wrote WordPress discovery index' , { phase: 'discovery' , step: 'write-artifact' , artifact: path. join (outDir, 'README.md' ) });
614 await fs. writeFile (
615 path. join (outDir, 'skipped-routes.json' ),
616 `${ JSON . stringify ( skippedRoutesPayload ( context ), null , 2 ) } \n ` ,
617 'utf8' ,
618 );
619 progress?. progress ( 'Wrote WordPress skipped-route index' , {
620 phase: 'discovery' ,
621 step: 'write-artifact' ,
622 artifact: path. join (outDir, 'skipped-routes.json' ),
623 });
624
625 for ( const entity of context.entities) {
626 await fs. writeFile (path. join (outDir, entity.fileName), renderEntityFile (entity), 'utf8' );
627 progress?. progress ( `Wrote WordPress discovery artifact for ${ entity . entityName }` , {
628 phase: 'discovery' ,
629 step: 'write-artifact' ,
630 entity: entity.routePath,
631 artifact: path. join (outDir, entity.fileName),
632 });
633 }
634 }
635
636 async function main () {
637 const parsed = parseProgressArgs (process.argv. slice ( 2 ));
638 progress = createProgressLogger ({
639 script: 'skills/replatform/resources/rp-source-wordpress/scripts/wp-discovery.js' ,
640 ... parsed.progress,
641 });
642 progress. start ( 'WordPress discovery started' , { phase: 'discovery' });
643
644 const args = parseArgs (parsed.args);
645 if (args.help) {
646 printUsage ();
647 progress. complete ( 'WordPress discovery help shown' , { phase: 'discovery' , step: 'help' });
648 return ;
649 }
650
651 if ( ! args.baseUrl || ! args.outDir) {
652 printUsage ();
653 progress. error ( 'Missing required WordPress discovery arguments' , { phase: 'discovery' });
654 throw new Error ( 'Missing required arguments: --base-url and --out-dir are required.' );
655 }
656
657 configureRateLimit ({ rateLimitRpm: args.rateLimitRpm, maxRetries: args.maxRetries });
658
659 const headers = buildHeaders (args);
660 const rootResponse = await fetchJson (args.baseUrl, '' , {
661 headers,
662 method: 'GET' ,
663 timeoutMs: args.timeoutMs,
664 progress,
665 progressContext: { step: 'rest-index' },
666 });
667
668 if ( ! rootResponse.ok || ! rootResponse.json) {
669 throw new Error ( `Failed to fetch WordPress REST index from ${ rootResponse . url }: ${ rootResponse . status } ${ rootResponse . statusText }` );
670 }
671
672 const indexJson = rootResponse.json;
673 const namespaces = Array. isArray (indexJson?.namespaces) ? indexJson.namespaces : [];
674 progress. progress ( 'WordPress namespace enumeration completed' , {
675 phase: 'discovery' ,
676 step: 'namespace-enumeration' ,
677 count: namespaces. length ,
678 unit: 'namespaces' ,
679 });
680 const candidates = deriveEntityCandidates (indexJson, args.includeNamespaces);
681 progress. progress ( 'WordPress endpoint candidates derived' , {
682 phase: 'discovery' ,
683 step: 'derive-endpoints' ,
684 count: candidates. length ,
685 unit: 'endpoints' ,
686 });
687 const classifications = classifyRoutes (candidates, summarizeOverrides (args));
688 const candidatesByRoute = new Map (candidates. map (( candidate ) => [candidate.routePath, candidate]));
689 const candidatesToInspect = classifications
690 . filter (( classification ) => [ 'sample' , 'metadata' ]. includes (classification.effectiveAction))
691 . map (( classification ) => ({
692 ... candidatesByRoute. get (classification.routePath),
693 classification,
694 }));
695 const skippedByCategory = summarizeSkippedByCategory (classifications);
696 const skippedRoutes = classifications. filter (( classification ) => classification.effectiveAction === 'skip' ). length ;
697 progress. progress ( 'WordPress endpoint candidates classified' , {
698 phase: 'discovery' ,
699 step: 'classify-endpoints' ,
700 count: candidatesToInspect. length ,
701 total: candidates. length ,
702 unit: 'endpoints' ,
703 details: {
704 sampledRoutes: candidatesToInspect. length ,
705 skippedRoutes,
706 skippedByCategory,
707 },
708 });
709 const entities = [];
710
711 for ( let i = 0 ; i < candidatesToInspect. length ; i += 1 ) {
712 const candidate = candidatesToInspect[i];
713 progress. progress ( `Inspecting WordPress endpoint ${ candidate . routePath }` , {
714 phase: 'discovery' ,
715 step: 'inspect-endpoint' ,
716 entity: candidate.routePath,
717 count: i + 1 ,
718 total: candidatesToInspect. length ,
719 unit: 'endpoints' ,
720 percent: candidatesToInspect. length ? Math. round (((i + 1 ) / candidatesToInspect. length ) * 100 ) : 100 ,
721 });
722 const details = await inspectEntity (args.baseUrl, headers, candidate, {
723 sampleLimit: args.sampleLimit,
724 timeoutMs: args.timeoutMs,
725 progress,
726 });
727 entities. push (details);
728 }
729
730 const context = {
731 generatedAt: new Date (). toISOString (),
732 baseUrl: normalizeBaseUrl (args.baseUrl),
733 restRoot: `${ normalizeBaseUrl ( args . baseUrl ) }/wp-json` ,
734 authMode: summarizeAuthMode (args),
735 authProviders: Object. keys (indexJson?.authentication || {}),
736 namespaces,
737 totalAdvertisedRoutes: Object. keys (indexJson?.routes || {}). length ,
738 totalCandidateRoutes: candidates. length ,
739 sampledRoutes: candidatesToInspect. length ,
740 skippedRoutes,
741 skippedByCategory,
742 overrides: summarizeOverrides (args),
743 classifications,
744 routeIndexSample: Object. keys (indexJson?.routes || {}). slice ( 0 , 50 ),
745 entities,
746 };
747
748 await writeOutputs (args.outDir, context, progress);
749
750 console. log ( `Wrote ${ entities . length + 1 } markdown files and skipped-routes.json to ${ args . outDir }` );
751 progress. complete ( 'WordPress discovery completed' , {
752 phase: 'discovery' ,
753 artifact: args.outDir,
754 count: entities. length ,
755 unit: 'entities' ,
756 });
757 }
758
759 if (require.main === module ) {
760 main (). catch (( error ) => {
761 console. error (error.stack || error.message);
762 if (progress) {
763 progress. error (error && error.message ? error.message : 'WordPress discovery failed' , { phase: 'discovery' });
764 }
765 process.exitCode = 1 ;
766 });
767 }
768
769 module . exports = {
770 parseArgs,
771 deriveEntityCandidates,
772 renderIndexFile,
773 skippedRoutesPayload,
774 };