explorbot 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,544 @@
1
+ import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
2
+ import type Explorer from '../../../../src/explorer.ts';
3
+ import type { WebPageState } from '../../../../src/state-manager.ts';
4
+
5
+ export interface DocStateTransition {
6
+ action: string;
7
+ before: string;
8
+ after: string;
9
+ targetUrl?: string;
10
+ discoveredUrls?: string[];
11
+ newCapabilities?: string[];
12
+ element?: InteractionElement;
13
+ changes?: InteractionChanges;
14
+ }
15
+
16
+ interface InteractionCandidate {
17
+ element: ResearchElement;
18
+ container?: string;
19
+ role: 'link' | 'button' | 'tab';
20
+ sectionName: string;
21
+ }
22
+
23
+ interface InteractionElement {
24
+ role: string;
25
+ name: string;
26
+ section: string;
27
+ container?: string;
28
+ locator?: string;
29
+ }
30
+
31
+ interface InteractionChanges {
32
+ urlChanged: boolean;
33
+ newElements: number;
34
+ removedElements: number;
35
+ }
36
+
37
+ const MAX_PRIMARY_CANDIDATES = 3;
38
+ const MAX_INTERACTIONS = 5;
39
+ const MAX_LINKS = 15;
40
+ const DEFAULT_WAIT_MS = 700;
41
+ const TAB_WAIT_MS = 500;
42
+
43
+ export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string): Promise<DocStateTransition[]> {
44
+ const sections = parseResearchSections(research);
45
+ const transitions: DocStateTransition[] = [];
46
+ const tabGroup = findTabGroup(sections);
47
+
48
+ if (tabGroup) {
49
+ transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url)));
50
+ }
51
+
52
+ for (const candidate of findActionCandidates(sections)) {
53
+ if (transitions.length >= MAX_INTERACTIONS) {
54
+ break;
55
+ }
56
+
57
+ const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS);
58
+ if (!transition) {
59
+ continue;
60
+ }
61
+
62
+ transitions.push(transition);
63
+ }
64
+
65
+ return transitions;
66
+ }
67
+
68
+ export function pickDocActionCandidates(research: string): Array<{ label: string; role: InteractionCandidate['role']; section: string }> {
69
+ return findActionCandidates(parseResearchSections(research)).map((candidate) => ({
70
+ label: candidate.element.name.trim(),
71
+ role: candidate.role,
72
+ section: candidate.sectionName,
73
+ }));
74
+ }
75
+
76
+ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string): Promise<DocStateTransition[]> {
77
+ const transitions: DocStateTransition[] = [];
78
+
79
+ for (const element of tabGroup.elements) {
80
+ const transition = await executeInteraction(
81
+ explorer,
82
+ {
83
+ element,
84
+ container: tabGroup.container,
85
+ role: 'tab',
86
+ sectionName: tabGroup.sectionName,
87
+ },
88
+ restoreUrl,
89
+ TAB_WAIT_MS
90
+ );
91
+ if (!transition) {
92
+ continue;
93
+ }
94
+
95
+ transitions.push(transition);
96
+ }
97
+
98
+ await restoreInteractionState(explorer, restoreUrl, buildPrimaryCommand(tabGroup.elements[0], tabGroup.container));
99
+ return transitions;
100
+ }
101
+
102
+ async function executeInteraction(explorer: Explorer, candidate: InteractionCandidate, restoreUrl: string, waitMs: number): Promise<DocStateTransition | null> {
103
+ const beforeState = explorer.getStateManager().getCurrentState();
104
+ if (!beforeState) {
105
+ return null;
106
+ }
107
+
108
+ const executed = await attemptInteraction(explorer, candidate);
109
+ if (!executed) {
110
+ return null;
111
+ }
112
+
113
+ await wait(waitMs);
114
+
115
+ const afterState = explorer.getStateManager().getCurrentState();
116
+ if (!afterState) {
117
+ return null;
118
+ }
119
+
120
+ const ariaChanges = countAriaChanges(beforeState.ariaSnapshot || '', afterState.ariaSnapshot || '');
121
+ const urlChanged = beforeState.url !== afterState.url;
122
+ const transition = buildTransition(candidate, beforeState, afterState, {
123
+ urlChanged,
124
+ newElements: ariaChanges.newCount,
125
+ removedElements: ariaChanges.removedCount,
126
+ });
127
+
128
+ if (urlChanged) {
129
+ await restoreInteractionState(explorer, restoreUrl);
130
+ }
131
+
132
+ return transition;
133
+ }
134
+
135
+ async function attemptInteraction(explorer: Explorer, candidate: InteractionCandidate): Promise<boolean> {
136
+ const action = explorer.createAction();
137
+
138
+ for (const command of buildClickCommands(candidate.element, candidate.container)) {
139
+ const success = await action.attempt(command, buildPurpose(candidate), false);
140
+ if (success) {
141
+ return true;
142
+ }
143
+ }
144
+
145
+ return false;
146
+ }
147
+
148
+ async function restoreInteractionState(explorer: Explorer, restoreUrl: string, primaryCommand?: string | null): Promise<void> {
149
+ if (primaryCommand) {
150
+ const action = explorer.createAction();
151
+ const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`, false);
152
+ if (restored) {
153
+ await wait(TAB_WAIT_MS);
154
+ return;
155
+ }
156
+ }
157
+
158
+ const action = explorer.createAction();
159
+ await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`, false);
160
+ }
161
+
162
+ function buildTransition(candidate: InteractionCandidate, beforeState: WebPageState, afterState: WebPageState, changes: InteractionChanges): DocStateTransition {
163
+ const transition: DocStateTransition = {
164
+ action: describeAction(candidate),
165
+ before: summarizeInteractiveState(beforeState),
166
+ after: summarizeInteractiveState(afterState),
167
+ discoveredUrls: collectLinks(afterState).map((link) => link.url),
168
+ newCapabilities: collectDiscoveryNotes(afterState, changes),
169
+ element: buildInteractionElement(candidate),
170
+ changes,
171
+ };
172
+
173
+ if (changes.urlChanged) {
174
+ transition.targetUrl = afterState.url;
175
+ }
176
+
177
+ return transition;
178
+ }
179
+
180
+ function buildInteractionElement(candidate: InteractionCandidate): InteractionElement {
181
+ const element: InteractionElement = {
182
+ role: candidate.role,
183
+ name: candidate.element.name.trim(),
184
+ section: candidate.sectionName,
185
+ };
186
+
187
+ if (candidate.container) {
188
+ element.container = candidate.container;
189
+ }
190
+ if (candidate.element.css || candidate.element.xpath) {
191
+ element.locator = candidate.element.css || candidate.element.xpath || undefined;
192
+ }
193
+
194
+ return element;
195
+ }
196
+
197
+ function collectDiscoveryNotes(state: WebPageState, changes: InteractionChanges): string[] {
198
+ const notes: string[] = [];
199
+ const headings = collectHeadings(state);
200
+ const links = collectLinks(state);
201
+
202
+ if (changes.urlChanged) {
203
+ notes.push('URL changed after interaction');
204
+ }
205
+ if (changes.newElements > 0) {
206
+ notes.push(`ARIA snapshot gained ${changes.newElements} elements`);
207
+ }
208
+ if (changes.removedElements > 0) {
209
+ notes.push(`ARIA snapshot removed ${changes.removedElements} elements`);
210
+ }
211
+ if (headings.length > 0) {
212
+ notes.push(`Visible headings after interaction: ${headings.slice(0, 3).join(' | ')}`);
213
+ }
214
+ if (links.length > 0) {
215
+ notes.push(`Visible links after interaction: ${Math.min(links.length, MAX_LINKS)}`);
216
+ }
217
+
218
+ return notes;
219
+ }
220
+
221
+ function findTabGroup(sections: ReturnType<typeof parseResearchSections>): { elements: ResearchElement[]; container?: string; sectionName: string } | null {
222
+ for (const section of sections) {
223
+ const sectionName = section.name.toLowerCase();
224
+ const container = section.containerCss?.toLowerCase() || '';
225
+ if (isOverlaySection(sectionName, container)) {
226
+ continue;
227
+ }
228
+
229
+ const elements = section.elements.filter((element) => getElementRole(element) === 'tab');
230
+ if (elements.length < 2 || elements.length > 6) {
231
+ continue;
232
+ }
233
+
234
+ return {
235
+ elements,
236
+ container: section.containerCss || undefined,
237
+ sectionName: section.name,
238
+ };
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ function findActionCandidates(sections: ReturnType<typeof parseResearchSections>): InteractionCandidate[] {
245
+ const candidates: InteractionCandidate[] = [];
246
+ const seen = new Set<string>();
247
+ const navigationLabels = collectNavigationLabels(sections);
248
+
249
+ for (const section of sections) {
250
+ const sectionName = section.name.toLowerCase();
251
+ const container = section.containerCss?.toLowerCase() || '';
252
+ if (isOverlaySection(sectionName, container)) {
253
+ continue;
254
+ }
255
+ if (isNavigationSection(sectionName)) {
256
+ continue;
257
+ }
258
+
259
+ for (const element of section.elements) {
260
+ const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels);
261
+ if (!candidate) {
262
+ continue;
263
+ }
264
+
265
+ const key = `${candidate.role}:${normalizeCandidateLabel(candidate.element.name)}`;
266
+ if (seen.has(key)) {
267
+ continue;
268
+ }
269
+
270
+ seen.add(key);
271
+ candidates.push(candidate);
272
+ }
273
+ }
274
+
275
+ return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, MAX_PRIMARY_CANDIDATES);
276
+ }
277
+
278
+ function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set<string>): InteractionCandidate | null {
279
+ const role = getElementRole(element);
280
+ if (role !== 'link' && role !== 'button' && role !== 'tab') {
281
+ return null;
282
+ }
283
+ if (!hasUsableName(element)) {
284
+ return null;
285
+ }
286
+ if (isShellLocator(element.css) || isShellLocator(element.xpath) || isShellLocator(container)) {
287
+ return null;
288
+ }
289
+ if (role === 'link' && navigationLabels.has(normalizeCandidateLabel(element.name))) {
290
+ return null;
291
+ }
292
+
293
+ return {
294
+ element,
295
+ container: container || undefined,
296
+ role,
297
+ sectionName,
298
+ };
299
+ }
300
+
301
+ function buildClickCommands(element: ResearchElement, container?: string): string[] {
302
+ const commands: string[] = [];
303
+
304
+ if (element.css) {
305
+ if (container && !element.css.startsWith(container)) {
306
+ commands.push(`I.click(${JSON.stringify(element.css)}, ${JSON.stringify(container)})`);
307
+ }
308
+ commands.push(`I.click(${JSON.stringify(element.css)})`);
309
+ }
310
+
311
+ if (element.aria) {
312
+ if (container) {
313
+ commands.push(`I.click(${JSON.stringify(element.aria)}, ${JSON.stringify(container)})`);
314
+ }
315
+ commands.push(`I.click(${JSON.stringify(element.aria)})`);
316
+ }
317
+
318
+ const xpath = buildXPathLocator(element);
319
+ if (xpath) {
320
+ commands.push(`I.click(${JSON.stringify(xpath)})`);
321
+ }
322
+
323
+ return [...new Set(commands)];
324
+ }
325
+
326
+ function buildPrimaryCommand(element: ResearchElement, container?: string): string | null {
327
+ return buildClickCommands(element, container)[0] || null;
328
+ }
329
+
330
+ function buildXPathLocator(element: ResearchElement): string | null {
331
+ if (!element.name) {
332
+ return null;
333
+ }
334
+
335
+ const text = xpathStringLiteral(element.name.trim());
336
+ const role = getElementRole(element);
337
+ if (role === 'link') {
338
+ return `//a[normalize-space()=${text}]`;
339
+ }
340
+ if (role === 'button' || role === 'tab') {
341
+ return `//*[self::button or @role="button" or @role="tab"][normalize-space()=${text}]`;
342
+ }
343
+
344
+ return `//*[normalize-space()=${text}]`;
345
+ }
346
+
347
+ function buildPurpose(candidate: InteractionCandidate): string {
348
+ return `Explore ${candidate.role} ${candidate.element.name.trim()}`;
349
+ }
350
+
351
+ function summarizeAria(aria: string): string {
352
+ const lines = aria.split('\n').filter((line) => line.trim());
353
+ if (lines.length === 0) {
354
+ return 'No elements';
355
+ }
356
+
357
+ const roleCounts: Record<string, number> = {};
358
+ for (const role of lines.map(extractAriaRole).filter((role): role is string => Boolean(role))) {
359
+ roleCounts[role] = (roleCounts[role] || 0) + 1;
360
+ }
361
+
362
+ const topRoles = Object.entries(roleCounts)
363
+ .sort((a, b) => b[1] - a[1])
364
+ .slice(0, 5)
365
+ .map(([role, count]) => `${role}:${count}`)
366
+ .join(', ');
367
+
368
+ return `${lines.length} elements (${topRoles})`;
369
+ }
370
+
371
+ function extractAriaRole(line: string): string | null {
372
+ const roleMatch = line.match(/\[role: ([\w-]+)\]/);
373
+ if (roleMatch) {
374
+ return roleMatch[1];
375
+ }
376
+
377
+ const yamlMatch = line.trim().match(/^- ([\w-]+)(?:\s|$|:)/);
378
+ if (yamlMatch) {
379
+ return yamlMatch[1];
380
+ }
381
+
382
+ return null;
383
+ }
384
+
385
+ function countAriaChanges(before: string, after: string): { newCount: number; removedCount: number } {
386
+ const beforeLines = new Set(before.split('\n').filter((line) => line.trim()));
387
+ const afterLines = new Set(after.split('\n').filter((line) => line.trim()));
388
+ let newCount = 0;
389
+ let removedCount = 0;
390
+
391
+ for (const line of afterLines) {
392
+ if (!beforeLines.has(line)) {
393
+ newCount++;
394
+ }
395
+ }
396
+
397
+ for (const line of beforeLines) {
398
+ if (!afterLines.has(line)) {
399
+ removedCount++;
400
+ }
401
+ }
402
+
403
+ return { newCount, removedCount };
404
+ }
405
+
406
+ function summarizeInteractiveState(state: WebPageState): string {
407
+ const parts = [summarizeAria(state.ariaSnapshot || '')];
408
+ const headings = collectHeadings(state).slice(0, 3);
409
+ const links = collectLinks(state).slice(0, 3);
410
+
411
+ if (state.url) {
412
+ parts.push(`URL ${state.url}`);
413
+ }
414
+ if (headings.length > 0) {
415
+ parts.push(`Headings: ${headings.map((heading) => limitInlineText(heading, 90)).join(' | ')}`);
416
+ }
417
+ if (links.length > 0) {
418
+ parts.push(`Links: ${links.map((link) => `${link.title} -> ${link.url}`).join('; ')}`);
419
+ }
420
+
421
+ return parts.join('. ');
422
+ }
423
+
424
+ function collectHeadings(state: { h1?: string; h2?: string; h3?: string; h4?: string }): string[] {
425
+ return [state.h1, state.h2, state.h3, state.h4].filter((heading): heading is string => Boolean(heading)).map((heading) => heading.trim());
426
+ }
427
+
428
+ function collectLinks(state: { links?: Array<{ title: string; url: string }> }): Array<{ title: string; url: string }> {
429
+ return (state.links || [])
430
+ .filter((link) => link.url)
431
+ .slice(0, MAX_LINKS)
432
+ .map((link) => ({
433
+ title: link.title || link.url,
434
+ url: link.url,
435
+ }));
436
+ }
437
+
438
+ function describeAction(candidate: InteractionCandidate): string {
439
+ return `Clicked ${candidate.role}: ${candidate.element.name.trim()}`;
440
+ }
441
+
442
+ function hasUsableName(element: ResearchElement): boolean {
443
+ const name = element.name.trim();
444
+ if (!name) {
445
+ return false;
446
+ }
447
+ if (name.length < 2) {
448
+ return false;
449
+ }
450
+ return true;
451
+ }
452
+
453
+ function isNavigationSection(sectionName: string): boolean {
454
+ return /(navigation|menu|header|footer|breadcrumb)/i.test(sectionName);
455
+ }
456
+
457
+ function isOverlaySection(sectionName: string, container: string): boolean {
458
+ return /(overlay|modal|popup|dialog)/i.test(sectionName) || /(overlay|modal|popup|dialog)/i.test(container);
459
+ }
460
+
461
+ function scoreCandidate(candidate: InteractionCandidate): number {
462
+ let score = 0;
463
+
464
+ if (candidate.role === 'link') {
465
+ score += 50;
466
+ }
467
+ if (candidate.role === 'button') {
468
+ score += 40;
469
+ }
470
+ if (candidate.role === 'tab') {
471
+ score += 30;
472
+ }
473
+ if (candidate.container) {
474
+ score += 10;
475
+ }
476
+ if (candidate.element.css) {
477
+ score += 5;
478
+ }
479
+ if (candidate.element.name.trim().length > 8) {
480
+ score += 5;
481
+ }
482
+
483
+ return score;
484
+ }
485
+
486
+ function isShellLocator(locator: string | null | undefined): boolean {
487
+ if (!locator) {
488
+ return false;
489
+ }
490
+
491
+ return /(nav\[role="navigation"\]|header|menu|breadcrumb|footer)/i.test(locator);
492
+ }
493
+
494
+ function collectNavigationLabels(sections: ReturnType<typeof parseResearchSections>): Set<string> {
495
+ const labels = new Set<string>();
496
+
497
+ for (const section of sections) {
498
+ if (!isNavigationSection(section.name.toLowerCase())) {
499
+ continue;
500
+ }
501
+
502
+ for (const element of section.elements) {
503
+ const label = normalizeCandidateLabel(element.name);
504
+ if (!label) {
505
+ continue;
506
+ }
507
+ labels.add(label);
508
+ }
509
+ }
510
+
511
+ return labels;
512
+ }
513
+
514
+ function normalizeCandidateLabel(label: string): string {
515
+ return label.trim().toLowerCase();
516
+ }
517
+
518
+ function limitInlineText(text: string, maxLength: number): string {
519
+ const normalized = text.replace(/\s+/g, ' ').trim();
520
+ if (normalized.length <= maxLength) {
521
+ return normalized;
522
+ }
523
+ return `${normalized.slice(0, maxLength - 3).trimEnd()}...`;
524
+ }
525
+
526
+ function getElementRole(element: ResearchElement): 'link' | 'button' | 'tab' | string {
527
+ return (element.aria?.role || element.type || '').toLowerCase();
528
+ }
529
+
530
+ function xpathStringLiteral(value: string): string {
531
+ if (!value.includes("'")) {
532
+ return `'${value}'`;
533
+ }
534
+ if (!value.includes('"')) {
535
+ return `"${value}"`;
536
+ }
537
+
538
+ const parts = value.split("'").map((part) => `'${part}'`);
539
+ return `concat(${parts.join(`, "'", `)})`;
540
+ }
541
+
542
+ async function wait(timeout: number): Promise<void> {
543
+ await new Promise((resolve) => setTimeout(resolve, timeout));
544
+ }
@@ -95,6 +95,7 @@ export function createDocsCommands(name = 'docs'): Command {
95
95
  maxPages: 100,
96
96
  output: 'docs',
97
97
  screenshot: true,
98
+ interactive: false,
98
99
  collapseDynamicPages: true,
99
100
  scope: 'site',
100
101
  includePaths: [],
@@ -114,6 +114,7 @@ class DocbotConfigParser {
114
114
  maxPages: 100,
115
115
  output: 'docs',
116
116
  screenshot: true,
117
+ interactive: false,
117
118
  collapseDynamicPages: true,
118
119
  scope: 'site',
119
120
  includePaths: [],
@@ -155,6 +156,7 @@ interface DocbotConfig {
155
156
  deniedPathSegments?: string[];
156
157
  minCanActions?: number;
157
158
  minInteractiveElements?: number;
159
+ interactive?: boolean;
158
160
  };
159
161
  }
160
162
 
@@ -41,7 +41,7 @@ class DocBot {
41
41
  config: this.options.docsConfig,
42
42
  path: this.options.path,
43
43
  });
44
- this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config);
44
+ this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
45
45
  this.ensureDirectory(this.configParser.getOutputDir());
46
46
  this.ensureDirectory(this.getPagesDir());
47
47
  }
@@ -128,18 +128,22 @@ class DocBot {
128
128
  summary: documentation.summary,
129
129
  canCount: documentation.can.length,
130
130
  mightCount: documentation.might.length,
131
+ interactionCount: (documentation.interactions || []).length,
131
132
  canActions: documentation.can.map((item) => item.action),
132
133
  mightActions: documentation.might.map((item) => item.action),
134
+ interactionActions: (documentation.interactions || []).map((item) => item.action),
135
+ qualityNotes: documentation.qualityNotes || [],
133
136
  filePath,
134
137
  });
135
138
  documented.add(pageKey);
136
139
 
137
- const nextPaths = this.extractNextPaths(state, baseUrl, research);
140
+ const nextPaths = this.extractNextPaths(state, baseUrl, research, documentation);
141
+ const interactionPriorityPaths = new Set(this.extractInteractionPaths(baseUrl, documentation));
138
142
  for (const nextPath of nextPaths) {
139
143
  if (documented.has(this.getPageKey(nextPath))) {
140
144
  continue;
141
145
  }
142
- if (stateManager.hasVisitedState(nextPath)) {
146
+ if (!interactionPriorityPaths.has(nextPath) && stateManager.hasVisitedState(nextPath)) {
143
147
  continue;
144
148
  }
145
149
  this.enqueuePath(nextPath, queue, queued);
@@ -185,10 +189,18 @@ class DocBot {
185
189
  return true;
186
190
  }
187
191
 
188
- private extractNextPaths(state: WebPageState, baseUrl: string, research: string): string[] {
192
+ private extractNextPaths(state: WebPageState, baseUrl: string, research: string, documentation?: PageDocumentation): string[] {
189
193
  const paths: string[] = [];
190
194
  const seen = new Set<string>();
191
195
 
196
+ for (const interactionPath of this.extractInteractionPaths(baseUrl, documentation)) {
197
+ if (seen.has(interactionPath)) {
198
+ continue;
199
+ }
200
+ seen.add(interactionPath);
201
+ paths.push(interactionPath);
202
+ }
203
+
192
204
  for (const link of state.links || []) {
193
205
  const nextPath = this.resolveLink(link, baseUrl);
194
206
  if (!nextPath) {
@@ -224,11 +236,58 @@ class DocBot {
224
236
  return paths;
225
237
  }
226
238
 
239
+ private extractInteractionPaths(baseUrl: string, documentation?: PageDocumentation): string[] {
240
+ const paths: string[] = [];
241
+ const seen = new Set<string>();
242
+ const interactions = documentation?.interactions;
243
+
244
+ for (const interaction of interactions || []) {
245
+ if (interaction.targetUrl) {
246
+ const nextPath = this.resolveRawUrl(interaction.targetUrl, baseUrl);
247
+ if (nextPath && this.isEligibleNextPath(nextPath) && !seen.has(nextPath)) {
248
+ seen.add(nextPath);
249
+ paths.push(nextPath);
250
+ }
251
+ }
252
+
253
+ for (const discoveredUrl of interaction.discoveredUrls || []) {
254
+ const discoveredPath = this.resolveRawUrl(discoveredUrl, baseUrl);
255
+ if (!discoveredPath) {
256
+ continue;
257
+ }
258
+ if (!this.isEligibleNextPath(discoveredPath)) {
259
+ continue;
260
+ }
261
+ if (seen.has(discoveredPath)) {
262
+ continue;
263
+ }
264
+ seen.add(discoveredPath);
265
+ paths.push(discoveredPath);
266
+ }
267
+ }
268
+
269
+ return paths;
270
+ }
271
+
272
+ private isEligibleNextPath(nextPath: string): boolean {
273
+ if (!shouldCrawlDocPath(nextPath, this.config)) {
274
+ return false;
275
+ }
276
+ if (!this.isInScope(nextPath)) {
277
+ return false;
278
+ }
279
+ return true;
280
+ }
281
+
227
282
  private resolveLink(link: Link, baseUrl: string): string | null {
283
+ return this.resolveRawUrl(link.url, baseUrl);
284
+ }
285
+
286
+ private resolveRawUrl(rawUrl: string, baseUrl: string): string | null {
228
287
  let resolved: URL;
229
288
 
230
289
  try {
231
- resolved = new URL(link.url, baseUrl);
290
+ resolved = new URL(rawUrl, baseUrl);
232
291
  } catch {
233
292
  return null;
234
293
  }