explorbot 0.4.9 → 0.5.0

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.
Files changed (144) hide show
  1. package/README.md +4 -1
  2. package/bin/mdq.ts +18 -0
  3. package/boat/api-tester/src/ai/chief.ts +72 -0
  4. package/boat/api-tester/src/api-client.ts +37 -0
  5. package/boat/prima/src/prima.ts +41 -2
  6. package/dist/bin/mdq.js +19 -0
  7. package/dist/boat/api-tester/src/ai/chief.js +69 -0
  8. package/dist/boat/api-tester/src/api-client.js +26 -0
  9. package/dist/boat/prima/src/prima.js +42 -2
  10. package/dist/package.json +3 -2
  11. package/dist/src/action.js +4 -2
  12. package/dist/src/ai/agent.d.ts +3 -1
  13. package/dist/src/ai/judge-provider.d.ts +17 -0
  14. package/dist/src/ai/judge-provider.js +56 -0
  15. package/dist/src/ai/judge-tool.d.ts +2 -0
  16. package/dist/src/ai/judge-tool.js +33 -0
  17. package/dist/src/ai/judge.d.ts +28 -0
  18. package/dist/src/ai/judge.js +71 -0
  19. package/dist/src/ai/navigator.d.ts +3 -1
  20. package/dist/src/ai/navigator.js +24 -28
  21. package/dist/src/ai/pilot.d.ts +4 -0
  22. package/dist/src/ai/pilot.js +67 -9
  23. package/dist/src/ai/planner.js +9 -6
  24. package/dist/src/ai/provider.d.ts +4 -1
  25. package/dist/src/ai/provider.js +52 -7
  26. package/dist/src/ai/rerunner.js +7 -0
  27. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  28. package/dist/src/ai/researcher/locators.js +2 -2
  29. package/dist/src/ai/researcher/pagination.js +1 -1
  30. package/dist/src/ai/researcher/research-result.js +2 -2
  31. package/dist/src/ai/researcher.js +1 -1
  32. package/dist/src/ai/task-agent.d.ts +2 -0
  33. package/dist/src/ai/task-agent.js +3 -1
  34. package/dist/src/ai/tester.js +19 -15
  35. package/dist/src/ai/tools.d.ts +4 -3
  36. package/dist/src/ai/tools.js +35 -7
  37. package/dist/src/api/request-result.js +2 -1
  38. package/dist/src/api/xhr-capture.js +2 -1
  39. package/dist/src/command-handler.d.ts +1 -0
  40. package/dist/src/command-handler.js +24 -3
  41. package/dist/src/commands/base-command.d.ts +5 -0
  42. package/dist/src/commands/base-command.js +3 -0
  43. package/dist/src/commands/explore-command.d.ts +2 -1
  44. package/dist/src/commands/explore-command.js +12 -1
  45. package/dist/src/commands/freesail-command.js +8 -2
  46. package/dist/src/commands/init-command.js +1 -1
  47. package/dist/src/commands/navigate-command.d.ts +2 -1
  48. package/dist/src/commands/navigate-command.js +6 -0
  49. package/dist/src/commands/plan-load-command.d.ts +2 -1
  50. package/dist/src/commands/plan-load-command.js +4 -0
  51. package/dist/src/commands/plans-command.d.ts +3 -9
  52. package/dist/src/commands/plans-command.js +11 -21
  53. package/dist/src/commands/rerun-command.d.ts +2 -1
  54. package/dist/src/commands/rerun-command.js +5 -1
  55. package/dist/src/commands/research-command.d.ts +2 -1
  56. package/dist/src/commands/research-command.js +6 -0
  57. package/dist/src/commands/test-command.d.ts +2 -1
  58. package/dist/src/commands/test-command.js +4 -1
  59. package/dist/src/components/Autocomplete.js +26 -12
  60. package/dist/src/components/InputReadline.js +10 -1
  61. package/dist/src/config.d.ts +6 -0
  62. package/dist/src/experience-tracker.js +4 -3
  63. package/dist/src/explorbot.d.ts +3 -0
  64. package/dist/src/explorbot.js +8 -0
  65. package/dist/src/explorer.js +2 -3
  66. package/dist/src/knowledge-tracker.js +1 -1
  67. package/dist/src/reporter.js +8 -4
  68. package/dist/src/state-manager.d.ts +2 -0
  69. package/dist/src/state-manager.js +16 -0
  70. package/dist/src/test-plan.d.ts +11 -0
  71. package/dist/src/test-plan.js +54 -2
  72. package/dist/src/utils/aria-ref.js +1 -1
  73. package/dist/src/utils/logger.js +9 -3
  74. package/dist/src/utils/markdown-query.d.ts +1 -48
  75. package/dist/src/utils/markdown-query.js +1 -444
  76. package/dist/src/utils/mdq/cli.d.ts +6 -0
  77. package/dist/src/utils/mdq/cli.js +122 -0
  78. package/dist/src/utils/mdq/edit.d.ts +24 -0
  79. package/dist/src/utils/mdq/edit.js +147 -0
  80. package/dist/src/utils/mdq/query.d.ts +118 -0
  81. package/dist/src/utils/mdq/query.js +451 -0
  82. package/dist/src/utils/step-analyzer.d.ts +3 -0
  83. package/dist/src/utils/step-analyzer.js +7 -0
  84. package/dist/src/utils/strings.d.ts +1 -0
  85. package/dist/src/utils/strings.js +7 -0
  86. package/dist/src/utils/test-files.d.ts +1 -0
  87. package/dist/src/utils/test-files.js +5 -2
  88. package/dist/src/utils/url-matcher.d.ts +1 -0
  89. package/dist/src/utils/url-matcher.js +7 -0
  90. package/docs/api-testing/planning.md +1 -1
  91. package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
  92. package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
  93. package/package.json +3 -2
  94. package/src/action.ts +4 -2
  95. package/src/ai/agent.ts +3 -1
  96. package/src/ai/judge-provider.ts +62 -0
  97. package/src/ai/judge-tool.ts +35 -0
  98. package/src/ai/judge.ts +75 -0
  99. package/src/ai/navigator.ts +24 -27
  100. package/src/ai/pilot.ts +68 -9
  101. package/src/ai/planner.ts +9 -6
  102. package/src/ai/provider.ts +51 -7
  103. package/src/ai/rerunner.ts +4 -0
  104. package/src/ai/researcher/deep-analysis.ts +2 -2
  105. package/src/ai/researcher/locators.ts +2 -2
  106. package/src/ai/researcher/pagination.ts +1 -1
  107. package/src/ai/researcher/research-result.ts +2 -2
  108. package/src/ai/researcher.ts +1 -1
  109. package/src/ai/task-agent.ts +4 -1
  110. package/src/ai/tester.ts +19 -16
  111. package/src/ai/tools.ts +42 -7
  112. package/src/api/request-result.ts +2 -1
  113. package/src/api/xhr-capture.ts +2 -1
  114. package/src/command-handler.ts +28 -3
  115. package/src/commands/base-command.ts +9 -0
  116. package/src/commands/explore-command.ts +15 -2
  117. package/src/commands/freesail-command.ts +9 -2
  118. package/src/commands/init-command.ts +1 -1
  119. package/src/commands/navigate-command.ts +8 -1
  120. package/src/commands/plan-load-command.ts +6 -1
  121. package/src/commands/plans-command.ts +13 -29
  122. package/src/commands/rerun-command.ts +7 -2
  123. package/src/commands/research-command.ts +8 -1
  124. package/src/commands/test-command.ts +6 -2
  125. package/src/components/Autocomplete.tsx +39 -10
  126. package/src/components/InputReadline.tsx +10 -1
  127. package/src/config.ts +1 -0
  128. package/src/experience-tracker.ts +4 -3
  129. package/src/explorbot.ts +8 -0
  130. package/src/explorer.ts +2 -2
  131. package/src/knowledge-tracker.ts +1 -1
  132. package/src/reporter.ts +8 -4
  133. package/src/state-manager.ts +16 -0
  134. package/src/test-plan.ts +67 -2
  135. package/src/utils/aria-ref.ts +1 -1
  136. package/src/utils/logger.ts +7 -2
  137. package/src/utils/markdown-query.ts +1 -519
  138. package/src/utils/mdq/cli.ts +118 -0
  139. package/src/utils/mdq/edit.ts +158 -0
  140. package/src/utils/mdq/query.ts +556 -0
  141. package/src/utils/step-analyzer.ts +8 -0
  142. package/src/utils/strings.ts +7 -0
  143. package/src/utils/test-files.ts +5 -2
  144. package/src/utils/url-matcher.ts +7 -0
package/src/test-plan.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import figures from 'figures';
5
5
  import type { ActionResult } from './action-result.ts';
6
6
  import { listSitePlanDirs } from './global-config.ts';
7
7
  import { WebPageState } from './state-manager.ts';
8
- import { tag } from './utils/logger.ts';
8
+ import { createDebug, pluralize, tag } from './utils/logger.ts';
9
+ import { truncateMiddle } from './utils/strings.ts';
9
10
  import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
10
11
  import { uniqSessionName } from './utils/unique-names.ts';
12
+ import { extractStatePath } from './utils/url-matcher.ts';
11
13
 
12
14
  export const TestResult = {
13
15
  PASSED: 'passed',
@@ -241,6 +243,7 @@ export class Test extends Task {
241
243
  endTime?: number;
242
244
  resetCount = 0;
243
245
  appliedExperience: AppliedExperience[] = [];
246
+ preparedData: string[] = [];
244
247
 
245
248
  constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps: string[] = []) {
246
249
  super(scenario, startUrl);
@@ -477,6 +480,39 @@ export class Plan {
477
480
 
478
481
  updateStatus(): void {}
479
482
 
483
+ static listFiles(plansDir: string): PlanFile[] {
484
+ if (!existsSync(plansDir)) return [];
485
+
486
+ const files = readdirSync(plansDir)
487
+ .filter((file) => file.endsWith('.md'))
488
+ .map((file) => {
489
+ const filePath = path.join(plansDir, file);
490
+ const modifiedAt = statSync(filePath).mtimeMs;
491
+ const plan = readPlanFile(filePath, modifiedAt);
492
+ return {
493
+ name: file,
494
+ path: filePath,
495
+ modifiedAt,
496
+ title: plan.title,
497
+ url: extractStatePath(plan.startUrl || ''),
498
+ testCount: plan.tests.length,
499
+ label: '',
500
+ };
501
+ })
502
+ .sort((left, right) => right.modifiedAt - left.modifiedAt);
503
+
504
+ if (files.length === 0) return files;
505
+
506
+ const nameWidth = Math.min(MAX_PLAN_NAME_WIDTH, Math.max(...files.map((file) => file.name.length)));
507
+ const urlWidth = Math.min(MAX_PLAN_URL_WIDTH, Math.max(...files.map((file) => file.url.length)));
508
+ for (const file of files) {
509
+ const name = truncateMiddle(file.name, nameWidth).padEnd(nameWidth);
510
+ const url = truncateMiddle(file.url, urlWidth).padEnd(urlWidth);
511
+ file.label = `${name} ${url} ${file.testCount} ${pluralize(file.testCount, 'test')}`;
512
+ }
513
+ return files;
514
+ }
515
+
480
516
  static resolveFile(file: string, plansDir?: string): string | null {
481
517
  const names = [file];
482
518
  if (!file.endsWith('.md')) names.push(`${file}.md`);
@@ -579,3 +615,32 @@ interface AppliedExperience {
579
615
  url: string;
580
616
  content: string;
581
617
  }
618
+
619
+ export interface PlanFile {
620
+ name: string;
621
+ path: string;
622
+ modifiedAt: number;
623
+ title: string;
624
+ url: string;
625
+ testCount: number;
626
+ label: string;
627
+ }
628
+
629
+ const debugLog = createDebug('explorbot:test-plan');
630
+ const MAX_PLAN_NAME_WIDTH = 38;
631
+ const MAX_PLAN_URL_WIDTH = 26;
632
+ const planFileCache = new Map<string, { modifiedAt: number; plan: Plan }>();
633
+
634
+ function readPlanFile(filePath: string, modifiedAt: number): Plan {
635
+ const cached = planFileCache.get(filePath);
636
+ if (cached?.modifiedAt === modifiedAt) return cached.plan;
637
+
638
+ let plan = new Plan(path.basename(filePath));
639
+ try {
640
+ plan = parsePlanFromMarkdown(filePath);
641
+ } catch (error) {
642
+ debugLog(`Failed to read plan file ${filePath}:`, error);
643
+ }
644
+ planFileCache.set(filePath, { modifiedAt, plan });
645
+ return plan;
646
+ }
@@ -1,6 +1,6 @@
1
1
  import { WebElement } from './web-element.ts';
2
2
 
3
- const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
3
+ const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=((?:f\d+)?e\d+)\]/;
4
4
  const ARIA_REF_PATTERN = /^(f\d+)?e\d+$/i;
5
5
 
6
6
  const REF_ROLES: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
@@ -103,7 +103,12 @@ class ConsoleDestination implements LogDestination {
103
103
  if (entry.type === 'debug') return;
104
104
  if (entry.type === 'html') return;
105
105
  if (entry.type === 'operation' && !this.verboseMode) return;
106
- if (entry.type === 'step' && !this.verboseMode && this.recentSteps.shouldSuppress(entry.content)) return;
106
+ if (entry.type === 'step' && !this.verboseMode) {
107
+ const step = entry.originalArgs?.[0];
108
+ const error = entry.originalArgs?.[1];
109
+ if (error || step?.failed === true || step?.status === 'failed') return;
110
+ if (this.recentSteps.shouldSuppress(entry.content)) return;
111
+ }
107
112
  let content = entry.content;
108
113
  if (entry.type === 'multiline' || entry.type === 'details') {
109
114
  const cleaned = stripAnsi(dedent(entry.content));
@@ -215,7 +220,7 @@ class SpanDestination implements LogDestination {
215
220
  if (!step?.toCode) {
216
221
  return;
217
222
  }
218
- const stepName = step?.name ? `I.${step.name}` : 'I.step';
223
+ const stepName = step?.title ? `I.${step.title}` : 'I.step';
219
224
  const stepInput = typeof step?.toCode === 'function' ? step.toCode() : entry.content;
220
225
  const errorFromStep = step?.error;
221
226
  const errorMessage =
@@ -1,519 +1 @@
1
- import { type Token, type Tokens, marked } from 'marked';
2
-
3
- export type SelectorType = 'section' | 'section1' | 'section2' | 'section3' | 'section4' | 'section5' | 'section6' | 'table' | 'heading' | 'paragraph' | 'list' | 'item' | 'code' | 'blockquote' | 'hr' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
4
-
5
- export interface TextMatcher {
6
- mode: 'exact' | 'contains' | 'regex';
7
- value: string;
8
- negated: boolean;
9
- }
10
-
11
- export interface QuerySegment {
12
- selector: SelectorType;
13
- textMatch?: TextMatcher;
14
- index: number | null;
15
- slice: { from?: number; to?: number } | null;
16
- }
17
-
18
- export interface MatchedRange {
19
- token: Token;
20
- start: number;
21
- length: number;
22
- innerTokens?: MatchedRange[];
23
- }
24
-
25
- export function parseQuery(input: string): QuerySegment[] {
26
- const segments: QuerySegment[] = [];
27
- let pos = 0;
28
-
29
- function peek(): string {
30
- return pos < input.length ? input[pos] : '';
31
- }
32
-
33
- function advance(): string {
34
- return input[pos++] || '';
35
- }
36
-
37
- function skipWhitespace() {
38
- while (pos < input.length && /\s/.test(input[pos])) pos++;
39
- }
40
-
41
- function readIdentifier(): string {
42
- const start = pos;
43
- while (pos < input.length && /[a-zA-Z_\d]/.test(input[pos])) pos++;
44
- return input.slice(start, pos);
45
- }
46
-
47
- function readQuotedString(): string {
48
- const quote = advance();
49
- let result = '';
50
- while (pos < input.length && input[pos] !== quote) {
51
- if (input[pos] === '\\') {
52
- pos++;
53
- result += input[pos] || '';
54
- } else {
55
- result += input[pos];
56
- }
57
- pos++;
58
- }
59
- if (pos < input.length) pos++;
60
- return result;
61
- }
62
-
63
- function readUntilAny(chars: string): string {
64
- const start = pos;
65
- while (pos < input.length && !chars.includes(input[pos])) pos++;
66
- return input.slice(start, pos);
67
- }
68
-
69
- function parseTextMatcher(): TextMatcher {
70
- let negated = false;
71
- if (peek() === '!') {
72
- negated = true;
73
- advance();
74
- }
75
-
76
- if (peek() === '~') {
77
- advance();
78
- const value = readQuotedString();
79
- return { mode: 'contains', value, negated };
80
- }
81
-
82
- if (peek() === '/') {
83
- advance();
84
- let value = '';
85
- while (pos < input.length && input[pos] !== '/') {
86
- value += input[pos];
87
- pos++;
88
- }
89
- if (pos < input.length) pos++;
90
- while (pos < input.length && /[gimsuy]/.test(input[pos])) pos++;
91
- return { mode: 'regex', value, negated };
92
- }
93
-
94
- const value = readQuotedString();
95
- return { mode: 'exact', value, negated };
96
- }
97
-
98
- while (pos < input.length) {
99
- skipWhitespace();
100
- if (pos >= input.length) break;
101
-
102
- const selector = readIdentifier();
103
- if (!selector) {
104
- pos++;
105
- continue;
106
- }
107
-
108
- const segment: QuerySegment = {
109
- selector: selector as SelectorType,
110
- index: null,
111
- slice: null,
112
- };
113
-
114
- if (peek() === '(') {
115
- advance();
116
- segment.textMatch = parseTextMatcher();
117
- if (peek() === ')') advance();
118
- }
119
-
120
- while (peek() === '[') {
121
- advance();
122
- const content = readUntilAny(']');
123
- if (/^-?\d*(:-?\d*)?$/.test(content) && content !== '') {
124
- if (content.includes(':')) {
125
- const colonIdx = content.indexOf(':');
126
- const fromStr = content.slice(0, colonIdx);
127
- const toStr = content.slice(colonIdx + 1);
128
- segment.slice = {
129
- from: fromStr ? Number.parseInt(fromStr, 10) : undefined,
130
- to: toStr ? Number.parseInt(toStr, 10) : undefined,
131
- };
132
- } else {
133
- segment.index = Number.parseInt(content, 10);
134
- }
135
- }
136
- if (peek() === ']') advance();
137
- }
138
-
139
- segments.push(segment);
140
- }
141
-
142
- return segments;
143
- }
144
-
145
- function matchText(text: string, matcher: TextMatcher): boolean {
146
- let result: boolean;
147
-
148
- switch (matcher.mode) {
149
- case 'exact':
150
- result = text === matcher.value;
151
- break;
152
- case 'contains':
153
- result = text.includes(matcher.value);
154
- break;
155
- case 'regex':
156
- result = new RegExp(matcher.value, 'i').test(text);
157
- break;
158
- default:
159
- result = false;
160
- }
161
-
162
- return matcher.negated ? !result : result;
163
- }
164
-
165
- function entryKey(line: string): string | null {
166
- const separator = line.indexOf(':');
167
- if (separator < 1) return null;
168
- return line.slice(0, separator).trim().toLowerCase();
169
- }
170
-
171
- function getTokenText(token: Token): string {
172
- const t = token as any;
173
- switch (token.type) {
174
- case 'heading':
175
- case 'paragraph':
176
- case 'code':
177
- case 'blockquote':
178
- case 'list_item':
179
- return t.text || '';
180
- case 'table':
181
- return (t.header || []).map((h: any) => h.text).join(', ');
182
- default:
183
- return '';
184
- }
185
- }
186
-
187
- function getHeadingDepth(selector: string): number | null {
188
- const match = selector.match(/^h([1-6])$/);
189
- return match ? Number.parseInt(match[1], 10) : null;
190
- }
191
-
192
- function isSectionSelector(selector: string): boolean {
193
- return /^section\d?$/.test(selector);
194
- }
195
-
196
- function getSectionDepth(selector: string): number | null {
197
- const match = selector.match(/^section([1-6])$/);
198
- return match ? Number.parseInt(match[1], 10) : null;
199
- }
200
-
201
- function selectorToTokenType(selector: string): string | null {
202
- if (/^h[1-6]$/.test(selector)) return 'heading';
203
- const map: Record<string, string> = {
204
- heading: 'heading',
205
- paragraph: 'paragraph',
206
- table: 'table',
207
- code: 'code',
208
- list: 'list',
209
- blockquote: 'blockquote',
210
- hr: 'hr',
211
- item: 'list_item',
212
- };
213
- return map[selector] || null;
214
- }
215
-
216
- function buildTokenIndex(source: string): MatchedRange[] {
217
- const tokens = marked.lexer(source);
218
- const ranges: MatchedRange[] = [];
219
- let offset = 0;
220
-
221
- for (const token of tokens) {
222
- const raw = (token as any).raw || '';
223
- ranges.push({ token, start: offset, length: raw.length });
224
- offset += raw.length;
225
- }
226
-
227
- return ranges;
228
- }
229
-
230
- function computeSections(candidates: MatchedRange[], segment: QuerySegment): MatchedRange[] {
231
- const sectionDepth = getSectionDepth(segment.selector as string);
232
- const sections: MatchedRange[] = [];
233
-
234
- for (let i = 0; i < candidates.length; i++) {
235
- const range = candidates[i];
236
- if (range.token.type !== 'heading') continue;
237
-
238
- const heading = range.token as Tokens.Heading;
239
- if (sectionDepth !== null && heading.depth !== sectionDepth) continue;
240
- if (segment.textMatch && !matchText(heading.text, segment.textMatch)) continue;
241
-
242
- const depth = heading.depth;
243
- const innerTokens: MatchedRange[] = [];
244
- let endOffset = range.start + range.length;
245
-
246
- for (let j = i + 1; j < candidates.length; j++) {
247
- const nextRange = candidates[j];
248
- if (nextRange.token.type === 'heading' && (nextRange.token as Tokens.Heading).depth <= depth) break;
249
- innerTokens.push(nextRange);
250
- endOffset = nextRange.start + nextRange.length;
251
- }
252
-
253
- sections.push({
254
- token: range.token,
255
- start: range.start,
256
- length: endOffset - range.start,
257
- innerTokens,
258
- });
259
- }
260
-
261
- return sections;
262
- }
263
-
264
- function extractListItems(candidates: MatchedRange[]): MatchedRange[] {
265
- const items: MatchedRange[] = [];
266
-
267
- for (const range of candidates) {
268
- if (range.token.type !== 'list') continue;
269
-
270
- const list = range.token as Tokens.List;
271
- const listRaw = (range.token as any).raw as string;
272
- let searchFrom = 0;
273
-
274
- for (const item of list.items) {
275
- const itemRaw = (item as any).raw as string;
276
- const idx = listRaw.indexOf(itemRaw, searchFrom);
277
- if (idx === -1) continue;
278
-
279
- items.push({
280
- token: item as unknown as Token,
281
- start: range.start + idx,
282
- length: itemRaw.length,
283
- });
284
-
285
- searchFrom = idx + itemRaw.length;
286
- }
287
- }
288
-
289
- return items;
290
- }
291
-
292
- function applyIndexSlice(matches: MatchedRange[], segment: QuerySegment): MatchedRange[] {
293
- if (segment.index !== null) {
294
- const idx = segment.index < 0 ? matches.length + segment.index : segment.index;
295
- return idx >= 0 && idx < matches.length ? [matches[idx]] : [];
296
- }
297
-
298
- if (segment.slice) {
299
- const { from, to } = segment.slice;
300
- return matches.slice(from, to);
301
- }
302
-
303
- return matches;
304
- }
305
-
306
- function expandSectionRanges(matches: MatchedRange[]): MatchedRange[] {
307
- let hasSection = false;
308
- for (const m of matches) {
309
- if (m.innerTokens) {
310
- hasSection = true;
311
- break;
312
- }
313
- }
314
- if (!hasSection) return matches;
315
-
316
- const expanded: MatchedRange[] = [];
317
- for (const m of matches) {
318
- if (m.innerTokens) {
319
- expanded.push({ token: m.token, start: m.start, length: ((m.token as any).raw || '').length });
320
- expanded.push(...m.innerTokens);
321
- } else {
322
- expanded.push(m);
323
- }
324
- }
325
- return expanded;
326
- }
327
-
328
- function executeSegments(candidates: MatchedRange[], segments: QuerySegment[]): MatchedRange[] {
329
- if (segments.length === 0) return candidates;
330
-
331
- const segment = segments[0];
332
- const remaining = segments.slice(1);
333
-
334
- if (isSectionSelector(segment.selector as string)) {
335
- const sections = computeSections(candidates, segment);
336
- const indexed = applyIndexSlice(sections, segment);
337
-
338
- if (remaining.length === 0) return indexed;
339
-
340
- const results: MatchedRange[] = [];
341
- for (const section of indexed) {
342
- results.push(...executeSegments(section.innerTokens || [], remaining));
343
- }
344
- return results;
345
- }
346
-
347
- if (segment.selector === 'item') {
348
- let items = extractListItems(candidates);
349
- if (segment.textMatch) {
350
- items = items.filter((r) => matchText(getTokenText(r.token), segment.textMatch!));
351
- }
352
- return executeSegments(applyIndexSlice(items, segment), remaining);
353
- }
354
-
355
- const tokenType = selectorToTokenType(segment.selector as string);
356
- if (!tokenType) return [];
357
-
358
- let matches = candidates.filter((r) => r.token.type === tokenType);
359
-
360
- const depth = getHeadingDepth(segment.selector as string);
361
- if (depth !== null) {
362
- matches = matches.filter((r) => (r.token as any).depth === depth);
363
- }
364
-
365
- if (segment.textMatch) {
366
- matches = matches.filter((r) => matchText(getTokenText(r.token), segment.textMatch!));
367
- }
368
-
369
- return executeSegments(applyIndexSlice(matches, segment), remaining);
370
- }
371
-
372
- export class MarkdownQuery {
373
- private source: string;
374
- private matches: MatchedRange[];
375
-
376
- constructor(source: string, matches?: MatchedRange[]) {
377
- this.source = source;
378
- this.matches = matches || buildTokenIndex(source);
379
- }
380
-
381
- query(selector: string): MarkdownQuery {
382
- const segments = parseQuery(selector);
383
- const candidates = expandSectionRanges(this.matches);
384
- const results = executeSegments(candidates, segments);
385
- return new MarkdownQuery(this.source, results);
386
- }
387
-
388
- text(): string {
389
- return this.matches.map((r) => this.source.slice(r.start, r.start + r.length)).join('');
390
- }
391
-
392
- get(): string {
393
- return this.text();
394
- }
395
-
396
- toJson(): Record<string, string>[] {
397
- const results: Record<string, string>[] = [];
398
-
399
- for (const range of this.matches) {
400
- if (range.token.type !== 'table') continue;
401
-
402
- const table = range.token as Tokens.Table;
403
- const headers = table.header.map((h) => h.text);
404
- for (const row of table.rows) {
405
- const obj: Record<string, string> = {};
406
- for (let i = 0; i < headers.length; i++) {
407
- obj[headers[i]] = row[i]?.text ?? '';
408
- }
409
- results.push(obj);
410
- }
411
- }
412
-
413
- return results;
414
- }
415
-
416
- keyValue(): Record<string, string> {
417
- const entries: Record<string, string> = {};
418
-
419
- for (const range of this.matches) {
420
- for (const line of getTokenText(range.token).split('\n')) {
421
- const key = entryKey(line);
422
- if (!key) continue;
423
- const value = line.slice(line.indexOf(':') + 1).trim();
424
- if (value) entries[key] = value;
425
- }
426
- }
427
-
428
- return entries;
429
- }
430
-
431
- setKeyValue(key: string, value: string | null): string {
432
- return this.replaceEach((match) => {
433
- const token = match.matches[0].token;
434
- const lines = getTokenText(token)
435
- .split('\n')
436
- .map((line) => line.trim())
437
- .filter(Boolean);
438
-
439
- const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
440
- if (index < 0 && value) lines.push(`${key}: ${value}`);
441
- if (index >= 0 && value) lines[index] = `${key}: ${value}`;
442
- if (index >= 0 && !value) lines.splice(index, 1);
443
-
444
- if (token.type !== 'blockquote') return lines.join('\n');
445
- return lines.map((line) => `> ${line}`).join('\n');
446
- });
447
- }
448
-
449
- replace(content: string): string {
450
- return this.replaceEach(() => content);
451
- }
452
-
453
- replaceEach(replacer: (match: MarkdownQuery, index: number) => string): string {
454
- const sorted = [...this.matches].sort((a, b) => a.start - b.start);
455
-
456
- const kept: MatchedRange[] = [];
457
- let lastEnd = -1;
458
- for (const range of sorted) {
459
- if (range.start < lastEnd) continue;
460
- kept.push(range);
461
- lastEnd = range.start + range.length;
462
- }
463
-
464
- const replacements = kept.map((range, index) => replacer(new MarkdownQuery(this.source, [range]), index));
465
- let result = this.source;
466
- for (let i = kept.length - 1; i >= 0; i--) {
467
- const range = kept[i];
468
- result = result.slice(0, range.start) + replacements[i] + result.slice(range.start + range.length);
469
- }
470
-
471
- return result;
472
- }
473
-
474
- count(): number {
475
- return this.matches.length;
476
- }
477
-
478
- first(): MarkdownQuery {
479
- return new MarkdownQuery(this.source, this.matches.slice(0, 1));
480
- }
481
-
482
- last(): MarkdownQuery {
483
- return new MarkdownQuery(this.source, this.matches.slice(-1));
484
- }
485
-
486
- before(): MarkdownQuery {
487
- if (this.matches.length === 0) return new MarkdownQuery(this.source, []);
488
- const cutoff = this.matches[0].start;
489
- const allTokens = buildTokenIndex(this.source);
490
- const beforeTokens = allTokens.filter((r) => r.start + r.length <= cutoff);
491
- return new MarkdownQuery(this.source, beforeTokens);
492
- }
493
-
494
- after(): MarkdownQuery {
495
- if (this.matches.length === 0) return new MarkdownQuery(this.source, []);
496
- const lastMatch = this.matches[this.matches.length - 1];
497
- const cutoff = lastMatch.start + lastMatch.length;
498
- const allTokens = buildTokenIndex(this.source);
499
- const afterTokens = allTokens.filter((r) => r.start >= cutoff);
500
- return new MarkdownQuery(this.source, afterTokens);
501
- }
502
-
503
- each(): MarkdownQuery[] {
504
- return this.matches.map((m) => new MarkdownQuery(this.source, [m]));
505
- }
506
-
507
- meta(): Array<{ type: string; depth: number | null; text: string }> {
508
- return this.matches.map((range) => {
509
- const token = range.token as any;
510
- let depth: number | null = null;
511
- if (token.type === 'heading') depth = token.depth;
512
- return { type: token.type, depth, text: getTokenText(range.token) };
513
- });
514
- }
515
- }
516
-
517
- export function mdq(source: string): MarkdownQuery {
518
- return new MarkdownQuery(source);
519
- }
1
+ export * from './mdq/query.ts';