explorbot 0.4.6 → 0.4.7
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.
- package/boat/api-tester/src/ai/curler.ts +70 -66
- package/boat/api-tester/src/apibot.ts +1 -0
- package/boat/api-tester/src/cli.ts +2 -0
- package/boat/api-tester/src/config.ts +18 -1
- package/dist/boat/api-tester/src/ai/curler.js +55 -56
- package/dist/boat/api-tester/src/apibot.js +1 -0
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/api-tester/src/config.js +3 -1
- package/dist/package.json +2 -2
- package/dist/rules/researcher/pagination.md +6 -0
- package/dist/src/action-result.d.ts +6 -0
- package/dist/src/action-result.js +12 -0
- package/dist/src/ai/planner.js +4 -0
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/pagination.d.ts +16 -0
- package/dist/src/ai/researcher/pagination.js +62 -0
- package/dist/src/ai/researcher/parser.d.ts +3 -0
- package/dist/src/ai/researcher/parser.js +22 -6
- package/dist/src/ai/researcher/sections.js +1 -1
- package/dist/src/ai/researcher.js +7 -2
- package/dist/src/ai/rules.js +16 -0
- package/dist/src/ai/scout.js +8 -2
- package/dist/src/ai/tools.js +10 -3
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +14 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +14 -11
- package/dist/src/remote.d.ts +2 -0
- package/dist/src/remote.js +23 -16
- package/dist/src/utils/aria.d.ts +2 -0
- package/dist/src/utils/aria.js +6 -1
- package/dist/src/utils/markdown-query.d.ts +2 -0
- package/dist/src/utils/markdown-query.js +39 -0
- package/dist/src/utils/pagination.d.ts +16 -0
- package/dist/src/utils/pagination.js +20 -0
- package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
- package/package.json +2 -2
- package/rules/researcher/pagination.md +6 -0
- package/src/action-result.ts +16 -0
- package/src/ai/planner.ts +4 -0
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher/pagination.ts +68 -0
- package/src/ai/researcher/parser.ts +23 -5
- package/src/ai/researcher/sections.ts +1 -1
- package/src/ai/researcher.ts +9 -3
- package/src/ai/rules.ts +16 -0
- package/src/ai/scout.ts +9 -2
- package/src/ai/tools.ts +7 -3
- package/src/commands/options/ws-option.ts +14 -0
- package/src/config.ts +15 -11
- package/src/remote.ts +22 -15
- package/src/utils/aria.ts +8 -1
- package/src/utils/markdown-query.ts +39 -0
- package/src/utils/pagination.ts +36 -0
|
@@ -63,7 +63,7 @@ export function WithSections(Base) {
|
|
|
63
63
|
}
|
|
64
64
|
async _researchSingleSection(name, description, ariaSnapshot, focusCss) {
|
|
65
65
|
const currentUrl = this.stateManager.getCurrentState()?.url || '';
|
|
66
|
-
const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
|
|
66
|
+
const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl);
|
|
67
67
|
const url = this.actionResult?.url || 'Unknown';
|
|
68
68
|
const title = this.actionResult?.title || 'Unknown';
|
|
69
69
|
let focusHint = '';
|
|
@@ -18,6 +18,7 @@ import { WithCoordinates } from "./researcher/coordinates.js";
|
|
|
18
18
|
import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
|
|
19
19
|
import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
|
|
20
20
|
import { WithLocators } from "./researcher/locators.js";
|
|
21
|
+
import { WithPagination } from "./researcher/pagination.js";
|
|
21
22
|
import { extractValidContainers, formatResearchSummary, parseResearchSections } from "./researcher/parser.js";
|
|
22
23
|
import { ResearchResult } from "./researcher/research-result.js";
|
|
23
24
|
import { WithSections } from "./researcher/sections.js";
|
|
@@ -33,7 +34,7 @@ export const POSSIBLE_SECTIONS = {
|
|
|
33
34
|
menu: 'page menu (toolbar, context actions, filters, dropdowns)',
|
|
34
35
|
navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
|
|
35
36
|
};
|
|
36
|
-
const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent))));
|
|
37
|
+
const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent)))));
|
|
37
38
|
export class Researcher extends ResearcherBase {
|
|
38
39
|
ACTION_TOOLS = ['click'];
|
|
39
40
|
emoji = '🔍';
|
|
@@ -230,6 +231,9 @@ export class Researcher extends ResearcherBase {
|
|
|
230
231
|
await this.backfillCoordinates(result);
|
|
231
232
|
await this.backfillBrokenLocators(result);
|
|
232
233
|
}
|
|
234
|
+
if (!interrupted()) {
|
|
235
|
+
await this.detectPagination(result);
|
|
236
|
+
}
|
|
233
237
|
// Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
|
|
234
238
|
if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
|
|
235
239
|
const sections = parseResearchSections(result.text);
|
|
@@ -366,7 +370,7 @@ export class Researcher extends ResearcherBase {
|
|
|
366
370
|
|
|
367
371
|
${generalLocatorRuleText}
|
|
368
372
|
|
|
369
|
-
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
|
|
373
|
+
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
|
|
370
374
|
|
|
371
375
|
<section_identification>
|
|
372
376
|
Identify page sections in this priority order:
|
|
@@ -439,6 +443,7 @@ export class Researcher extends ResearcherBase {
|
|
|
439
443
|
- When a section contains a list of similar data items (records, entities, rows — content that varies by data, not by app UI), output it as a Data section with NO table.
|
|
440
444
|
- Data section heading MUST be a level-2 heading (##) that starts exactly with "Data:" — for example: "## Data: Suites List". Do NOT use ### or add section numbers.
|
|
441
445
|
- Data sections must NOT include a UI map table. Only include the container and a brief summary line.
|
|
446
|
+
- When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
|
|
442
447
|
- Example data section:
|
|
443
448
|
|
|
444
449
|
## Data: Suites List
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -332,6 +332,22 @@ export const actionRule = dedent `
|
|
|
332
332
|
For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
|
|
333
333
|
|
|
334
334
|
|
|
335
|
+
### I.scrollTo
|
|
336
|
+
|
|
337
|
+
scrolls until the element is in view
|
|
338
|
+
|
|
339
|
+
I.scrollTo(<locator>)
|
|
340
|
+
|
|
341
|
+
Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
|
|
342
|
+
that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
|
|
343
|
+
|
|
344
|
+
<example>
|
|
345
|
+
I.scrollTo('.rows > *:last-child');
|
|
346
|
+
I.scrollTo({ role: 'listitem', text: 'Last entry' });
|
|
347
|
+
I.scrollPageToBottom();
|
|
348
|
+
</example>
|
|
349
|
+
|
|
350
|
+
|
|
335
351
|
### I.fillField
|
|
336
352
|
|
|
337
353
|
fills the field with the given value
|
package/dist/src/ai/scout.js
CHANGED
|
@@ -47,12 +47,18 @@ export class Scout {
|
|
|
47
47
|
maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
|
|
48
48
|
agentName: 'scout',
|
|
49
49
|
});
|
|
50
|
+
const responseText = invokeResult?.response?.text;
|
|
51
|
+
if (responseText?.trim()) {
|
|
52
|
+
finishFromText(responseText);
|
|
53
|
+
stop();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
50
56
|
if (!invokeResult?.toolExecutions?.length) {
|
|
51
|
-
finishFromText(invokeResult?.response?.text);
|
|
52
57
|
stop();
|
|
53
58
|
return;
|
|
54
59
|
}
|
|
55
|
-
if (iteration >= MAX_ITERATIONS) {
|
|
60
|
+
if (iteration >= MAX_ITERATIONS - 1) {
|
|
61
|
+
conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
|
|
56
62
|
const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
|
|
57
63
|
finishFromText(final?.response?.text);
|
|
58
64
|
stop();
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -311,6 +311,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
311
311
|
- Working with iframes (switch context with I.switchTo)
|
|
312
312
|
- Performing multiple form actions in a single batch
|
|
313
313
|
- Complex interactions requiring sequential commands
|
|
314
|
+
- Reaching items further down a list (I.scrollTo)
|
|
314
315
|
|
|
315
316
|
Example - filling a form with context (PREFERRED):
|
|
316
317
|
I.fillField('Username', 'John', '.login-form')
|
|
@@ -365,10 +366,10 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
365
366
|
}
|
|
366
367
|
if (!hasObservablePageChange(toolResult)) {
|
|
367
368
|
activeNote.commit(TestResult.FAILED);
|
|
368
|
-
return failedToolResult('form', '
|
|
369
|
+
return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
|
|
369
370
|
...toolResult,
|
|
370
371
|
code: codeBlock,
|
|
371
|
-
suggestion: '
|
|
372
|
+
suggestion: 'The command ran without reaching anything. Re-locate the target, check whether another UI layer is active, then retry. If the goal was to load more of a list, no change means the collection has ended.',
|
|
372
373
|
});
|
|
373
374
|
}
|
|
374
375
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
@@ -1080,7 +1081,13 @@ export function successToolResult(action, data, source) {
|
|
|
1080
1081
|
return result;
|
|
1081
1082
|
}
|
|
1082
1083
|
export function isMajorPageChange(pageDiff) {
|
|
1083
|
-
|
|
1084
|
+
if (pageDiff.urlChanged === true)
|
|
1085
|
+
return false;
|
|
1086
|
+
if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD)
|
|
1087
|
+
return false;
|
|
1088
|
+
if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0)
|
|
1089
|
+
return false;
|
|
1090
|
+
return true;
|
|
1084
1091
|
}
|
|
1085
1092
|
export function hasFailedRequest(pageDiff) {
|
|
1086
1093
|
return (pageDiff.requests ?? []).some((request) => request.status >= 400);
|
|
@@ -3,5 +3,12 @@ import { BaseOption } from './base-option.js';
|
|
|
3
3
|
export declare class WsOption extends BaseOption {
|
|
4
4
|
flags: string;
|
|
5
5
|
description: string;
|
|
6
|
+
/**
|
|
7
|
+
* An open socket holds the event loop, and `WebSocket` has no `unref` on
|
|
8
|
+
* either runtime — so a command that ends by returning rather than through
|
|
9
|
+
* `showStatsAndExit` would never exit once it is attached. The option that
|
|
10
|
+
* opened the connection is what closes it.
|
|
11
|
+
*/
|
|
12
|
+
register(command: Command): void;
|
|
6
13
|
apply(options: Record<string, any>, command: Command): void;
|
|
7
14
|
}
|
|
@@ -3,6 +3,20 @@ import { BaseOption } from './base-option.js';
|
|
|
3
3
|
export class WsOption extends BaseOption {
|
|
4
4
|
flags = '--ws <url>';
|
|
5
5
|
description = 'Stream this run to a remote UI over WebSocket';
|
|
6
|
+
/**
|
|
7
|
+
* An open socket holds the event loop, and `WebSocket` has no `unref` on
|
|
8
|
+
* either runtime — so a command that ends by returning rather than through
|
|
9
|
+
* `showStatsAndExit` would never exit once it is attached. The option that
|
|
10
|
+
* opened the connection is what closes it.
|
|
11
|
+
*/
|
|
12
|
+
register(command) {
|
|
13
|
+
super.register(command);
|
|
14
|
+
command.hook('postAction', async () => {
|
|
15
|
+
if (!remote.isAttached())
|
|
16
|
+
return;
|
|
17
|
+
await remote.close(0);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
6
20
|
apply(options, command) {
|
|
7
21
|
const url = options.ws || process.env.EXPLORBOT_WS_URL;
|
|
8
22
|
if (!url)
|
package/dist/src/config.d.ts
CHANGED
|
@@ -287,6 +287,7 @@ export declare function modelName(model: unknown): string;
|
|
|
287
287
|
export declare function modelProvider(model: unknown): string;
|
|
288
288
|
export declare function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>;
|
|
289
289
|
export declare function resolveConfigModels(ai?: AIConfig): Promise<void>;
|
|
290
|
+
export declare function resolveLangfuse(ai?: AIConfig): void;
|
|
290
291
|
export declare function resolveOutputRoot(baseUrl?: string): string;
|
|
291
292
|
export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
|
|
292
293
|
export declare function materializeKnowledge(outputRoot: string): void;
|
package/dist/src/config.js
CHANGED
|
@@ -377,17 +377,7 @@ export class ConfigParser {
|
|
|
377
377
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
378
378
|
config.playwright.url = options.baseUrl;
|
|
379
379
|
}
|
|
380
|
-
|
|
381
|
-
const langfuse = config.ai.langfuse;
|
|
382
|
-
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
383
|
-
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
384
|
-
config.ai.langfuse = {
|
|
385
|
-
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
386
|
-
publicKey,
|
|
387
|
-
secretKey,
|
|
388
|
-
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
389
|
-
};
|
|
390
|
-
}
|
|
380
|
+
resolveLangfuse(config.ai);
|
|
391
381
|
return config;
|
|
392
382
|
}
|
|
393
383
|
validateConfig(config) {
|
|
@@ -540,6 +530,19 @@ export async function resolveConfigModels(ai) {
|
|
|
540
530
|
agent.model = await resolveModel(agent.model);
|
|
541
531
|
}
|
|
542
532
|
}
|
|
533
|
+
export function resolveLangfuse(ai) {
|
|
534
|
+
if (!ai)
|
|
535
|
+
return;
|
|
536
|
+
const langfuse = ai.langfuse;
|
|
537
|
+
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
538
|
+
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
539
|
+
ai.langfuse = {
|
|
540
|
+
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
541
|
+
publicKey,
|
|
542
|
+
secretKey,
|
|
543
|
+
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
543
546
|
export function resolveOutputRoot(baseUrl) {
|
|
544
547
|
if (cachedOutputRoot)
|
|
545
548
|
return cachedOutputRoot;
|
package/dist/src/remote.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare class Remote implements LogDestination {
|
|
|
18
18
|
asks: Map<string, (value: string | null) => void>;
|
|
19
19
|
askCounter: number;
|
|
20
20
|
lastActivity: string | null;
|
|
21
|
+
closing: Promise<void> | null;
|
|
21
22
|
attach(url: string, command: string): void;
|
|
22
23
|
isAttached(): boolean;
|
|
23
24
|
send(type: string, data?: Record<string, unknown>): void;
|
|
@@ -30,6 +31,7 @@ export declare class Remote implements LogDestination {
|
|
|
30
31
|
* back out of flattened text.
|
|
31
32
|
*/
|
|
32
33
|
write(entry: TaggedLogEntry): void;
|
|
34
|
+
shutdown(exitCode: number): Promise<void>;
|
|
33
35
|
connect(): void;
|
|
34
36
|
reconnect(): void;
|
|
35
37
|
flush(): Promise<void>;
|
package/dist/src/remote.js
CHANGED
|
@@ -26,6 +26,7 @@ export class Remote {
|
|
|
26
26
|
asks = new Map();
|
|
27
27
|
askCounter = 0;
|
|
28
28
|
lastActivity = null;
|
|
29
|
+
closing = null;
|
|
29
30
|
attach(url, command) {
|
|
30
31
|
if (this.url)
|
|
31
32
|
return;
|
|
@@ -68,23 +69,12 @@ export class Remote {
|
|
|
68
69
|
});
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
close(exitCode) {
|
|
72
73
|
if (!this.url)
|
|
73
|
-
return;
|
|
74
|
-
this.
|
|
75
|
-
|
|
76
|
-
this.
|
|
77
|
-
if (this.reconnectTimer)
|
|
78
|
-
clearTimeout(this.reconnectTimer);
|
|
79
|
-
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
80
|
-
// route them into a closed socket and park them until the ask times out.
|
|
81
|
-
executionController.clearInputCallback();
|
|
82
|
-
for (const resolve of this.asks.values())
|
|
83
|
-
resolve(null);
|
|
84
|
-
this.asks.clear();
|
|
85
|
-
this.queue = [];
|
|
86
|
-
this.socket?.close();
|
|
87
|
-
this.socket = null;
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
if (!this.closing)
|
|
76
|
+
this.closing = this.shutdown(exitCode);
|
|
77
|
+
return this.closing;
|
|
88
78
|
}
|
|
89
79
|
isEnabled() {
|
|
90
80
|
return this.isAttached();
|
|
@@ -112,6 +102,23 @@ export class Remote {
|
|
|
112
102
|
error: this.errorOf(entry.originalArgs),
|
|
113
103
|
});
|
|
114
104
|
}
|
|
105
|
+
async shutdown(exitCode) {
|
|
106
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
107
|
+
await this.flush();
|
|
108
|
+
this.url = null;
|
|
109
|
+
if (this.reconnectTimer)
|
|
110
|
+
clearTimeout(this.reconnectTimer);
|
|
111
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
112
|
+
// route them into a closed socket and park them until the ask times out.
|
|
113
|
+
executionController.clearInputCallback();
|
|
114
|
+
for (const resolve of this.asks.values())
|
|
115
|
+
resolve(null);
|
|
116
|
+
this.asks.clear();
|
|
117
|
+
this.queue = [];
|
|
118
|
+
this.socket?.close();
|
|
119
|
+
this.socket = null;
|
|
120
|
+
this.closing = null;
|
|
121
|
+
}
|
|
115
122
|
connect() {
|
|
116
123
|
if (!this.url)
|
|
117
124
|
return;
|
package/dist/src/utils/aria.d.ts
CHANGED
package/dist/src/utils/aria.js
CHANGED
|
@@ -511,7 +511,12 @@ export const diffAriaSnapshots = (previous, current) => {
|
|
|
511
511
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
512
512
|
const added = [...byCount.added, ...renames.added];
|
|
513
513
|
const removed = [...byCount.removed, ...renames.removed];
|
|
514
|
-
return {
|
|
514
|
+
return {
|
|
515
|
+
text: formatDiff(added, removed, toggled, typed),
|
|
516
|
+
count: added.length + removed.length + toggled.length + typed.length,
|
|
517
|
+
added: added.length,
|
|
518
|
+
removed: removed.length,
|
|
519
|
+
};
|
|
515
520
|
};
|
|
516
521
|
export const detectFocusArea = (snapshot) => {
|
|
517
522
|
let tree = parseSnapshot(snapshot);
|
|
@@ -29,6 +29,8 @@ export declare class MarkdownQuery {
|
|
|
29
29
|
text(): string;
|
|
30
30
|
get(): string;
|
|
31
31
|
toJson(): Record<string, string>[];
|
|
32
|
+
keyValue(): Record<string, string>;
|
|
33
|
+
setKeyValue(key: string, value: string | null): string;
|
|
32
34
|
replace(content: string): string;
|
|
33
35
|
replaceEach(replacer: (match: MarkdownQuery, index: number) => string): string;
|
|
34
36
|
count(): number;
|
|
@@ -129,6 +129,12 @@ function matchText(text, matcher) {
|
|
|
129
129
|
}
|
|
130
130
|
return matcher.negated ? !result : result;
|
|
131
131
|
}
|
|
132
|
+
function entryKey(line) {
|
|
133
|
+
const separator = line.indexOf(':');
|
|
134
|
+
if (separator < 1)
|
|
135
|
+
return null;
|
|
136
|
+
return line.slice(0, separator).trim().toLowerCase();
|
|
137
|
+
}
|
|
132
138
|
function getTokenText(token) {
|
|
133
139
|
const t = token;
|
|
134
140
|
switch (token.type) {
|
|
@@ -340,6 +346,39 @@ export class MarkdownQuery {
|
|
|
340
346
|
}
|
|
341
347
|
return results;
|
|
342
348
|
}
|
|
349
|
+
keyValue() {
|
|
350
|
+
const entries = {};
|
|
351
|
+
for (const range of this.matches) {
|
|
352
|
+
for (const line of getTokenText(range.token).split('\n')) {
|
|
353
|
+
const key = entryKey(line);
|
|
354
|
+
if (!key)
|
|
355
|
+
continue;
|
|
356
|
+
const value = line.slice(line.indexOf(':') + 1).trim();
|
|
357
|
+
if (value)
|
|
358
|
+
entries[key] = value;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return entries;
|
|
362
|
+
}
|
|
363
|
+
setKeyValue(key, value) {
|
|
364
|
+
return this.replaceEach((match) => {
|
|
365
|
+
const token = match.matches[0].token;
|
|
366
|
+
const lines = getTokenText(token)
|
|
367
|
+
.split('\n')
|
|
368
|
+
.map((line) => line.trim())
|
|
369
|
+
.filter(Boolean);
|
|
370
|
+
const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
|
|
371
|
+
if (index < 0 && value)
|
|
372
|
+
lines.push(`${key}: ${value}`);
|
|
373
|
+
if (index >= 0 && value)
|
|
374
|
+
lines[index] = `${key}: ${value}`;
|
|
375
|
+
if (index >= 0 && !value)
|
|
376
|
+
lines.splice(index, 1);
|
|
377
|
+
if (token.type !== 'blockquote')
|
|
378
|
+
return lines.join('\n');
|
|
379
|
+
return lines.map((line) => `> ${line}`).join('\n');
|
|
380
|
+
});
|
|
381
|
+
}
|
|
343
382
|
replace(content) {
|
|
344
383
|
return this.replaceEach(() => content);
|
|
345
384
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function inspectList(css: string): ListMeasure | null;
|
|
2
|
+
export declare function restoreScroll({ css, scrollTop, pageScrollY }: ScrollPosition): void;
|
|
3
|
+
export type PaginationStrategy = 'controls' | 'infinite';
|
|
4
|
+
export interface ListMeasure {
|
|
5
|
+
hasPagingControls: boolean;
|
|
6
|
+
isFeed: boolean;
|
|
7
|
+
scrolls: boolean;
|
|
8
|
+
items: number;
|
|
9
|
+
scrollTop: number;
|
|
10
|
+
pageScrollY: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ScrollPosition {
|
|
13
|
+
css: string;
|
|
14
|
+
scrollTop: number;
|
|
15
|
+
pageScrollY: number;
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function inspectList(css) {
|
|
2
|
+
const element = document.querySelector(css);
|
|
3
|
+
if (!element)
|
|
4
|
+
return null;
|
|
5
|
+
const rect = element.getBoundingClientRect();
|
|
6
|
+
return {
|
|
7
|
+
hasPagingControls: !!element.querySelector('a[rel="next"], a[rel="prev"]'),
|
|
8
|
+
isFeed: element.matches('[role="feed"]') || !!element.querySelector('[role="feed"]'),
|
|
9
|
+
scrolls: element.scrollHeight > element.clientHeight + 1 || rect.bottom > window.innerHeight,
|
|
10
|
+
items: element.querySelectorAll(':scope > *').length,
|
|
11
|
+
scrollTop: element.scrollTop,
|
|
12
|
+
pageScrollY: window.scrollY,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function restoreScroll({ css, scrollTop, pageScrollY }) {
|
|
16
|
+
const element = document.querySelector(css);
|
|
17
|
+
if (element)
|
|
18
|
+
element.scrollTop = scrollTop;
|
|
19
|
+
window.scrollTo(0, pageScrollY);
|
|
20
|
+
}
|