foxmcp 1.5.0__py3-none-any.whl
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.
- foxmcp/__init__.py +8 -0
- foxmcp/browser_proxy.py +257 -0
- foxmcp/cli.py +14 -0
- foxmcp/examples/dom-summarize.sh +207 -0
- foxmcp/examples/gcal-cal-event-js.sh +310 -0
- foxmcp/examples/gcal-daily-events-js.sh +192 -0
- foxmcp/examples/gcal-monthly-events-js.sh +196 -0
- foxmcp/examples/set-file-input.py +28 -0
- foxmcp/examples/youtube-play-pause.sh +63 -0
- foxmcp/file_transfer.py +160 -0
- foxmcp/mcp_tools.py +2659 -0
- foxmcp/requirements.txt +5 -0
- foxmcp/server.py +1184 -0
- foxmcp/single_instance.py +208 -0
- foxmcp/version.py +3 -0
- foxmcp-1.5.0.dist-info/METADATA +272 -0
- foxmcp-1.5.0.dist-info/RECORD +20 -0
- foxmcp-1.5.0.dist-info/WHEEL +4 -0
- foxmcp-1.5.0.dist-info/entry_points.txt +2 -0
- foxmcp-1.5.0.dist-info/licenses/LICENSE +21 -0
foxmcp/__init__.py
ADDED
foxmcp/browser_proxy.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Discovery and browser-command forwarding on the extension's existing port."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from websockets.exceptions import ConnectionClosed
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
SERVER_NAME = "FoxMCP"
|
|
18
|
+
PROTOCOL_VERSION = 2
|
|
19
|
+
EXTENSION_ORIGIN_PATTERN = re.compile(r"moz-extension://.+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BrowserProxyHub:
|
|
23
|
+
"""Route extension and local-client sockets without mixing their identities."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, server):
|
|
26
|
+
self.server = server
|
|
27
|
+
self.clients = {}
|
|
28
|
+
self.idle_timeout = None # Manually started servers have no idle shutdown.
|
|
29
|
+
self.last_departure = time.monotonic()
|
|
30
|
+
self.stopping = False
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _is_local_host(authority):
|
|
34
|
+
# Validate the authority independently of the listener port: a local
|
|
35
|
+
# TCP forwarder preserves Host while connecting to a different port.
|
|
36
|
+
match = re.fullmatch(
|
|
37
|
+
r"(?:localhost|127\.0\.0\.1|\[::1\])(?::([0-9]{1,5}))?",
|
|
38
|
+
authority.lower(),
|
|
39
|
+
)
|
|
40
|
+
return match is not None and (
|
|
41
|
+
match[1] is None or 1 <= int(match[1]) <= 65535
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def discovery(self):
|
|
45
|
+
return {
|
|
46
|
+
"server": SERVER_NAME,
|
|
47
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
48
|
+
"proxy_path": "/proxy",
|
|
49
|
+
"pid": os.getpid(),
|
|
50
|
+
"lifetime": "automatic" if self.idle_timeout is not None else "manual",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def process_request(self, connection, request):
|
|
54
|
+
"""Apply route-specific access checks before a socket can replace Firefox."""
|
|
55
|
+
host = request.headers.get_all("Host")
|
|
56
|
+
origin = request.headers.get_all("Origin")
|
|
57
|
+
remote = ipaddress.ip_address(connection.remote_address[0])
|
|
58
|
+
if not remote.is_loopback or len(host) != 1 or not self._is_local_host(host[0]):
|
|
59
|
+
return connection.respond(HTTPStatus.FORBIDDEN, "Localhost clients only\n")
|
|
60
|
+
|
|
61
|
+
if request.path == "/":
|
|
62
|
+
if not request.headers.get_all("Upgrade"):
|
|
63
|
+
return connection.respond(
|
|
64
|
+
HTTPStatus.UPGRADE_REQUIRED, "WebSocket required\n"
|
|
65
|
+
)
|
|
66
|
+
if len(origin) != 1 or not EXTENSION_ORIGIN_PATTERN.fullmatch(origin[0]):
|
|
67
|
+
return connection.respond(
|
|
68
|
+
HTTPStatus.FORBIDDEN, "Extension origin required\n"
|
|
69
|
+
)
|
|
70
|
+
elif request.path in ("/discovery", "/proxy"):
|
|
71
|
+
# Browser scripts always send Origin on WebSockets. Discovery has
|
|
72
|
+
# no CORS allowance either; reject even simple HTTP page requests.
|
|
73
|
+
if origin:
|
|
74
|
+
return connection.respond(
|
|
75
|
+
HTTPStatus.FORBIDDEN, "Browser origins are refused\n"
|
|
76
|
+
)
|
|
77
|
+
if self.stopping:
|
|
78
|
+
return connection.respond(
|
|
79
|
+
HTTPStatus.SERVICE_UNAVAILABLE, "Server stopping\n"
|
|
80
|
+
)
|
|
81
|
+
if request.path == "/discovery":
|
|
82
|
+
response = connection.respond(
|
|
83
|
+
HTTPStatus.OK, json.dumps(self.discovery())
|
|
84
|
+
)
|
|
85
|
+
del response.headers["Content-Type"]
|
|
86
|
+
response.headers["Content-Type"] = "application/json"
|
|
87
|
+
response.headers["Cache-Control"] = "no-store"
|
|
88
|
+
return response
|
|
89
|
+
else:
|
|
90
|
+
return connection.respond(HTTPStatus.NOT_FOUND, "Unknown endpoint\n")
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
async def handle_connection(self, websocket):
|
|
94
|
+
if websocket.request.path == "/proxy":
|
|
95
|
+
await self.handle_proxy(websocket)
|
|
96
|
+
else:
|
|
97
|
+
await self.server.handle_extension_connection(websocket)
|
|
98
|
+
|
|
99
|
+
async def handle_proxy(self, websocket):
|
|
100
|
+
owner = f"proxy:{uuid.uuid4().hex}"
|
|
101
|
+
self.clients[websocket] = owner
|
|
102
|
+
pending = {}
|
|
103
|
+
try:
|
|
104
|
+
await websocket.send(json.dumps({"type": "hello", **self.discovery()}))
|
|
105
|
+
async for message in websocket:
|
|
106
|
+
try:
|
|
107
|
+
request = json.loads(message)
|
|
108
|
+
if not isinstance(request, dict):
|
|
109
|
+
raise ValueError("Request must be an object")
|
|
110
|
+
request_id = request.get("id")
|
|
111
|
+
timeout = request.get("timeout", 30.0)
|
|
112
|
+
if (
|
|
113
|
+
not isinstance(request_id, str)
|
|
114
|
+
or not request_id
|
|
115
|
+
or request.get("type") != "request"
|
|
116
|
+
or not isinstance(request.get("action"), str)
|
|
117
|
+
or not isinstance(request.get("data", {}), dict)
|
|
118
|
+
or not isinstance(timeout, (float, int))
|
|
119
|
+
or isinstance(timeout, bool)
|
|
120
|
+
or not 0 < timeout <= 300
|
|
121
|
+
or not math.isfinite(timeout)
|
|
122
|
+
):
|
|
123
|
+
raise ValueError("Invalid browser request")
|
|
124
|
+
if request_id in pending:
|
|
125
|
+
raise ValueError("Request ID is already in flight")
|
|
126
|
+
except (ValueError, TypeError):
|
|
127
|
+
await websocket.close(code=1008, reason="Invalid browser request")
|
|
128
|
+
break
|
|
129
|
+
task = asyncio.create_task(
|
|
130
|
+
self.forward(websocket, owner, request, timeout)
|
|
131
|
+
)
|
|
132
|
+
pending[request_id] = task
|
|
133
|
+
task.add_done_callback(
|
|
134
|
+
lambda _task, key=request_id: pending.pop(key, None)
|
|
135
|
+
)
|
|
136
|
+
except ConnectionClosed:
|
|
137
|
+
pass
|
|
138
|
+
finally:
|
|
139
|
+
# A start-monitoring call may finish after the client goes away.
|
|
140
|
+
# Finish tracking its reply before cleaning up everything it owned.
|
|
141
|
+
await asyncio.gather(*list(pending.values()), return_exceptions=True)
|
|
142
|
+
self.clients.pop(websocket, None)
|
|
143
|
+
try:
|
|
144
|
+
await self.server.stop_monitors_of_gone_clients()
|
|
145
|
+
finally:
|
|
146
|
+
self.last_departure = time.monotonic()
|
|
147
|
+
|
|
148
|
+
async def forward(self, websocket, owner, request, timeout):
|
|
149
|
+
original_id = request["id"]
|
|
150
|
+
action = request["action"]
|
|
151
|
+
try:
|
|
152
|
+
if action == "proxy.status":
|
|
153
|
+
response = {
|
|
154
|
+
"type": "response",
|
|
155
|
+
"action": action,
|
|
156
|
+
"data": {"status": await self.server.get_extension_status()},
|
|
157
|
+
}
|
|
158
|
+
else:
|
|
159
|
+
upstream = {**request, "id": uuid.uuid4().hex}
|
|
160
|
+
upstream.pop("timeout", None)
|
|
161
|
+
response = await self.server.send_request_and_wait(
|
|
162
|
+
upstream, timeout=timeout
|
|
163
|
+
)
|
|
164
|
+
if response.get("type") == "response":
|
|
165
|
+
data = response.get("data", {})
|
|
166
|
+
if action == "requests.start_monitoring":
|
|
167
|
+
self.server.register_monitor(data.get("monitor_id"), owner)
|
|
168
|
+
elif action == "requests.stop_monitoring":
|
|
169
|
+
self.server.forget_monitor(
|
|
170
|
+
request.get("data", {}).get("monitor_id")
|
|
171
|
+
)
|
|
172
|
+
await websocket.send(json.dumps({**response, "id": original_id}))
|
|
173
|
+
except ConnectionClosed:
|
|
174
|
+
pass
|
|
175
|
+
except Exception:
|
|
176
|
+
logger.exception("Browser proxy request failed: %s", action)
|
|
177
|
+
try:
|
|
178
|
+
await websocket.send(
|
|
179
|
+
json.dumps(
|
|
180
|
+
{"id": original_id, "error": "Browser proxy request failed"}
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
except ConnectionClosed:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
async def wait_until_idle(self):
|
|
187
|
+
while True:
|
|
188
|
+
await asyncio.sleep(min(self.idle_timeout, 1.0))
|
|
189
|
+
if (
|
|
190
|
+
not self.clients
|
|
191
|
+
and time.monotonic() - self.last_departure >= self.idle_timeout
|
|
192
|
+
):
|
|
193
|
+
self.stopping = True
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class RemoteBrowser:
|
|
198
|
+
"""The browser interface used by local MCP tools, over a shared proxy socket."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, websocket, file_transfers):
|
|
201
|
+
self.websocket = websocket
|
|
202
|
+
self.file_transfers = file_transfers
|
|
203
|
+
self.pending = {}
|
|
204
|
+
self.reader = asyncio.create_task(self.read_responses())
|
|
205
|
+
|
|
206
|
+
async def read_responses(self):
|
|
207
|
+
try:
|
|
208
|
+
async for message in self.websocket:
|
|
209
|
+
response = json.loads(message)
|
|
210
|
+
future = self.pending.get(response.get("id"))
|
|
211
|
+
if future is not None and not future.done():
|
|
212
|
+
future.set_result(response)
|
|
213
|
+
except (ConnectionClosed, ValueError, TypeError, AttributeError):
|
|
214
|
+
pass
|
|
215
|
+
finally:
|
|
216
|
+
for future in self.pending.values():
|
|
217
|
+
if not future.done():
|
|
218
|
+
future.set_result(
|
|
219
|
+
{
|
|
220
|
+
"error": "Browser proxy disconnected; reconnect the MCP client"
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def send_request_and_wait(self, request, timeout=30.0):
|
|
225
|
+
if self.reader.done():
|
|
226
|
+
return {"error": "Browser proxy disconnected; reconnect the MCP client"}
|
|
227
|
+
request_id = request["id"]
|
|
228
|
+
if request_id in self.pending:
|
|
229
|
+
return {"error": "Request ID is already in flight"}
|
|
230
|
+
future = asyncio.get_running_loop().create_future()
|
|
231
|
+
self.pending[request_id] = future
|
|
232
|
+
try:
|
|
233
|
+
await self.websocket.send(json.dumps({**request, "timeout": timeout}))
|
|
234
|
+
return await asyncio.wait_for(future, timeout + 1)
|
|
235
|
+
except ConnectionClosed:
|
|
236
|
+
return {"error": "Browser proxy disconnected; reconnect the MCP client"}
|
|
237
|
+
except asyncio.TimeoutError:
|
|
238
|
+
return {"error": f"Request timed out after {timeout} seconds"}
|
|
239
|
+
finally:
|
|
240
|
+
self.pending.pop(request_id, None)
|
|
241
|
+
|
|
242
|
+
async def get_extension_status(self):
|
|
243
|
+
response = await self.send_request_and_wait(
|
|
244
|
+
{
|
|
245
|
+
"id": uuid.uuid4().hex,
|
|
246
|
+
"type": "request",
|
|
247
|
+
"action": "proxy.status",
|
|
248
|
+
"data": {},
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
return response.get("data", {}).get("status") or response.get(
|
|
252
|
+
"error", "Unknown browser status"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
async def close(self):
|
|
256
|
+
await self.websocket.close()
|
|
257
|
+
await self.reader
|
foxmcp/cli.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Console entry point for the installed FoxMCP distribution."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .server import run
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""Use bundled examples unless configured, then start the existing server."""
|
|
11
|
+
examples = Path(__file__).with_name("examples")
|
|
12
|
+
if examples.is_dir():
|
|
13
|
+
os.environ.setdefault("FOXMCP_EXT_SCRIPTS", str(examples))
|
|
14
|
+
run()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# foxmcp-description: List the page's visible interactive elements as an indented tree, tagging each with a persistent dsid attribute to select it by later
|
|
3
|
+
# foxmcp-args: [onscreen [withpos]]
|
|
4
|
+
# DOM Summarization Script
|
|
5
|
+
# Extracts visible interactive elements (input, textarea, select, button, a, option) from the current page
|
|
6
|
+
# Assigns persistent dsid attributes to each element for future reference
|
|
7
|
+
# Outputs a hierarchical structure with indentation showing nesting relationships
|
|
8
|
+
#
|
|
9
|
+
# Usage: Called via foxmcp content_execute_predefined
|
|
10
|
+
# Arguments:
|
|
11
|
+
# $1 - Optional: "onscreen" to filter only elements visible in viewport
|
|
12
|
+
# $2 - Optional: "withpos" to include position/size info (only valid with "onscreen")
|
|
13
|
+
#
|
|
14
|
+
# Output format:
|
|
15
|
+
# - Elements show only dsid attribute (and type for input, value/selected for options)
|
|
16
|
+
# - With "withpos": adds pos="x,y,width,height" attribute showing element's screen position
|
|
17
|
+
# - Text nodes shown as quoted strings without prefix
|
|
18
|
+
# - 2-space indentation shows nesting hierarchy
|
|
19
|
+
# - Non-interesting containers are flattened (their children promoted to parent level)
|
|
20
|
+
|
|
21
|
+
FILTER_MODE="${1:-all}"
|
|
22
|
+
INCLUDE_POS="${2:-}"
|
|
23
|
+
|
|
24
|
+
# Validate that withpos is only used with onscreen
|
|
25
|
+
if [ "$INCLUDE_POS" = "withpos" ] && [ "$FILTER_MODE" != "onscreen" ]; then
|
|
26
|
+
echo "Error: 'withpos' option requires 'onscreen' mode" >&2
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Generate JavaScript with filter mode injected
|
|
31
|
+
cat << 'OUTER_EOF'
|
|
32
|
+
(function() {
|
|
33
|
+
OUTER_EOF
|
|
34
|
+
|
|
35
|
+
echo " const FILTER_MODE = \"${FILTER_MODE}\";"
|
|
36
|
+
echo " const INCLUDE_POS = \"${INCLUDE_POS}\";"
|
|
37
|
+
|
|
38
|
+
cat << 'INNER_EOF'
|
|
39
|
+
let dsIdCounter = 1;
|
|
40
|
+
|
|
41
|
+
// Find the highest existing dsid to continue numbering from there
|
|
42
|
+
const allElements = document.querySelectorAll('[dsid]');
|
|
43
|
+
allElements.forEach(el => {
|
|
44
|
+
const id = parseInt(el.getAttribute('dsid'));
|
|
45
|
+
if (id >= dsIdCounter) {
|
|
46
|
+
dsIdCounter = id + 1;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function isElementVisible(element) {
|
|
51
|
+
if (!element) return false;
|
|
52
|
+
|
|
53
|
+
const style = window.getComputedStyle(element);
|
|
54
|
+
|
|
55
|
+
if (style.display === 'none') return false;
|
|
56
|
+
if (style.visibility === 'hidden') return false;
|
|
57
|
+
if (parseFloat(style.opacity) === 0) return false;
|
|
58
|
+
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isElementOnScreen(element) {
|
|
63
|
+
if (!element) return false;
|
|
64
|
+
|
|
65
|
+
const rect = element.getBoundingClientRect();
|
|
66
|
+
return (
|
|
67
|
+
rect.top < window.innerHeight &&
|
|
68
|
+
rect.bottom > 0 &&
|
|
69
|
+
rect.left < window.innerWidth &&
|
|
70
|
+
rect.right > 0
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hasEventListeners(element) {
|
|
75
|
+
for (let attr of element.attributes) {
|
|
76
|
+
if (attr.name.startsWith('on')) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isInterestingElement(node) {
|
|
84
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
|
85
|
+
const tagName = node.tagName.toLowerCase();
|
|
86
|
+
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || tagName === 'button' || tagName === 'a' || tagName === 'option') {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (hasEventListeners(node)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function traverseDOM(node, depth = 0) {
|
|
96
|
+
const result = [];
|
|
97
|
+
const indent = ' '.repeat(depth);
|
|
98
|
+
|
|
99
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
100
|
+
const text = node.textContent.trim();
|
|
101
|
+
if (text.length > 0) {
|
|
102
|
+
result.push(`${indent}"${text.substring(0, 100)}${text.length > 100 ? '...' : ''}"`);
|
|
103
|
+
}
|
|
104
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
105
|
+
if (!isElementVisible(node)) {
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Apply onscreen filter if requested
|
|
110
|
+
if (FILTER_MODE === 'onscreen' && !isElementOnScreen(node)) {
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const isInteresting = isInterestingElement(node);
|
|
115
|
+
|
|
116
|
+
if (isInteresting) {
|
|
117
|
+
// Assign dsid if not already present
|
|
118
|
+
if (!node.hasAttribute('dsid')) {
|
|
119
|
+
node.setAttribute('dsid', dsIdCounter++);
|
|
120
|
+
}
|
|
121
|
+
const dsId = node.getAttribute('dsid');
|
|
122
|
+
const tagName = node.tagName.toLowerCase();
|
|
123
|
+
|
|
124
|
+
let elementStr = `${indent}<${tagName} dsid="${dsId}"`;
|
|
125
|
+
|
|
126
|
+
// Include type attribute for input elements
|
|
127
|
+
if (tagName === 'input') {
|
|
128
|
+
const type = node.type || 'text';
|
|
129
|
+
elementStr += ` type="${type}"`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Only include value and selected attributes for options
|
|
133
|
+
if (tagName === 'option') {
|
|
134
|
+
if (node.value) elementStr += ` value="${node.value}"`;
|
|
135
|
+
if (node.selected) elementStr += ` selected="true"`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Include position and size if requested
|
|
139
|
+
if (INCLUDE_POS === 'withpos') {
|
|
140
|
+
const rect = node.getBoundingClientRect();
|
|
141
|
+
const x = Math.round(rect.left);
|
|
142
|
+
const y = Math.round(rect.top);
|
|
143
|
+
const width = Math.round(rect.width);
|
|
144
|
+
const height = Math.round(rect.height);
|
|
145
|
+
elementStr += ` pos="${x},${y},${width},${height}"`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
elementStr += '>';
|
|
149
|
+
result.push(elementStr);
|
|
150
|
+
|
|
151
|
+
for (let child of node.childNodes) {
|
|
152
|
+
result.push(...traverseDOM(child, depth + 1));
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
// Not interesting, flatten: process children at same depth
|
|
156
|
+
for (let child of node.childNodes) {
|
|
157
|
+
result.push(...traverseDOM(child, depth));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function concatenateAdjacentText(lines) {
|
|
166
|
+
const result = [];
|
|
167
|
+
let i = 0;
|
|
168
|
+
|
|
169
|
+
while (i < lines.length) {
|
|
170
|
+
const line = lines[i];
|
|
171
|
+
const indent = line.match(/^(\s*)/)[1];
|
|
172
|
+
const isText = line.trim().startsWith('"');
|
|
173
|
+
|
|
174
|
+
if (isText) {
|
|
175
|
+
// Collect all adjacent text nodes at the same indentation level
|
|
176
|
+
let concatenated = line.trim().slice(1, -1); // Remove quotes
|
|
177
|
+
let j = i + 1;
|
|
178
|
+
|
|
179
|
+
while (j < lines.length) {
|
|
180
|
+
const nextLine = lines[j];
|
|
181
|
+
const nextIndent = nextLine.match(/^(\s*)/)[1];
|
|
182
|
+
const nextIsText = nextLine.trim().startsWith('"');
|
|
183
|
+
|
|
184
|
+
if (nextIsText && nextIndent === indent) {
|
|
185
|
+
concatenated += ' ' + nextLine.trim().slice(1, -1);
|
|
186
|
+
j++;
|
|
187
|
+
} else {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
result.push(`${indent}"${concatenated}"`);
|
|
193
|
+
i = j;
|
|
194
|
+
} else {
|
|
195
|
+
result.push(line);
|
|
196
|
+
i++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const output = traverseDOM(document.body);
|
|
204
|
+
const concatenated = concatenateAdjacentText(output);
|
|
205
|
+
return concatenated.join('\n');
|
|
206
|
+
})();
|
|
207
|
+
INNER_EOF
|