textweb 0.2.3 → 0.2.5
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/README.md +39 -2
- package/mcp/index.js +5 -1
- package/package.json +5 -2
- package/src/app-runtime/manifest.js +220 -0
- package/src/app-runtime/topics.js +46 -0
- package/src/cli.js +47 -5
- package/src/ensure-browser.js +92 -0
- package/src/renderer.js +171 -5
- package/src/server.js +213 -4
package/README.md
CHANGED
|
@@ -26,10 +26,19 @@ npx playwright install chromium
|
|
|
26
26
|
# Render any page
|
|
27
27
|
textweb https://news.ycombinator.com
|
|
28
28
|
|
|
29
|
+
# Explicitly request grid mode (same as default)
|
|
30
|
+
textweb --output grid https://news.ycombinator.com
|
|
31
|
+
|
|
32
|
+
# Semantic JSON output for agent workflows
|
|
33
|
+
textweb --output semantic https://example.com
|
|
34
|
+
|
|
35
|
+
# Hybrid output (grid + semantic metadata)
|
|
36
|
+
textweb --output hybrid https://example.com
|
|
37
|
+
|
|
29
38
|
# Interactive mode
|
|
30
39
|
textweb --interactive https://github.com
|
|
31
40
|
|
|
32
|
-
# JSON output
|
|
41
|
+
# Legacy JSON output (backward compatible)
|
|
33
42
|
textweb --json https://example.com
|
|
34
43
|
```
|
|
35
44
|
|
|
@@ -176,10 +185,11 @@ curl -X POST http://localhost:3000/scroll -d '{"direction": "down"}'
|
|
|
176
185
|
const { AgentBrowser } = require('textweb');
|
|
177
186
|
|
|
178
187
|
const browser = new AgentBrowser({ cols: 120 });
|
|
179
|
-
const { view, elements, meta } = await browser.navigate('https://example.com');
|
|
188
|
+
const { view, elements, semantic, meta } = await browser.navigate('https://example.com');
|
|
180
189
|
|
|
181
190
|
console.log(view); // The text grid
|
|
182
191
|
console.log(elements); // { 0: { selector, tag, text, href }, ... }
|
|
192
|
+
console.log(semantic); // { mode, url, title, elements: [...] }
|
|
183
193
|
console.log(meta.stats); // { totalElements, interactiveElements, renderMs }
|
|
184
194
|
|
|
185
195
|
await browser.click(3); // Click element [3]
|
|
@@ -275,6 +285,33 @@ Useful session tools:
|
|
|
275
285
|
- `textweb_session_list` → inspect active sessions
|
|
276
286
|
- `textweb_session_close` → close one session or all
|
|
277
287
|
|
|
288
|
+
## App Runtime Prototype (Manifest + LARC)
|
|
289
|
+
|
|
290
|
+
This repository now includes an early scaffold for a manifest-driven user runtime shell (separate from low-level raw admin tooling):
|
|
291
|
+
|
|
292
|
+
- Manifest validator: `src/app-runtime/manifest.js`
|
|
293
|
+
- PAN topic contract: `src/app-runtime/topics.js`
|
|
294
|
+
- Runtime shell + left nav + tabbed content: `canvas/app-runtime/app-shell.html`
|
|
295
|
+
- Sample manifest: `canvas/app-runtime/sample-app.json`
|
|
296
|
+
|
|
297
|
+
The runtime shell uses [LARC](https://github.com/larcjs/larc) PAN (`@larcjs/core-lite`) for in-page event communication, with a local fallback bus if the module cannot be loaded.
|
|
298
|
+
|
|
299
|
+
To open the prototype:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
# Start API server for integration hooks (save manifest/components)
|
|
303
|
+
npm run serve
|
|
304
|
+
|
|
305
|
+
# In another terminal, open the runtime shell
|
|
306
|
+
open /Users/cdr/Projects/textweb/canvas/app-runtime/app-shell.html
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Integration actions implemented on the API server:
|
|
310
|
+
- `POST /integrations/sync_saved_form`
|
|
311
|
+
- `POST /integrations/save_manifest`
|
|
312
|
+
- `POST /integrations/upsert_nav_item`
|
|
313
|
+
- `POST /integrations/runtime_state`
|
|
314
|
+
|
|
278
315
|
## Testing
|
|
279
316
|
|
|
280
317
|
```bash
|
package/mcp/index.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const { AgentBrowser } = require('../src/browser');
|
|
14
|
+
const { ensureBrowser } = require('../src/ensure-browser');
|
|
14
15
|
|
|
15
16
|
const SERVER_INFO = {
|
|
16
17
|
name: 'textweb',
|
|
@@ -462,4 +463,7 @@ function main() {
|
|
|
462
463
|
});
|
|
463
464
|
}
|
|
464
465
|
|
|
465
|
-
main()
|
|
466
|
+
ensureBrowser().then(main).catch((err) => {
|
|
467
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "textweb",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "A text-grid web renderer for AI agents — see the web without screenshots",
|
|
5
5
|
"main": "src/browser.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node src/cli.js",
|
|
12
12
|
"serve": "node src/server.js",
|
|
13
|
-
"test": "node test/test-form.js && node test/test-live.js && node test/test-ats-e2e.js",
|
|
13
|
+
"test": "node test/app-runtime-manifest.js && node test/runtime-integrations.js && node test/output-modes.js && node test/test-form.js && node test/test-live.js && node test/test-ats-e2e.js",
|
|
14
|
+
"test:app-runtime": "node test/app-runtime-manifest.js",
|
|
15
|
+
"test:integrations": "node test/runtime-integrations.js",
|
|
16
|
+
"test:output": "node test/output-modes.js",
|
|
14
17
|
"test:form": "node test/test-form.js",
|
|
15
18
|
"test:live": "node test/test-live.js",
|
|
16
19
|
"test:ats": "node test/test-ats-e2e.js"
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
(function initManifestModule(root, factory) {
|
|
2
|
+
if (typeof module === 'object' && module.exports) {
|
|
3
|
+
module.exports = factory();
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
root.TextWebAppManifest = factory();
|
|
7
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this, function manifestFactory() {
|
|
8
|
+
const APP_MANIFEST_VERSION = '1';
|
|
9
|
+
|
|
10
|
+
const LIFECYCLE_EVENTS = Object.freeze([
|
|
11
|
+
'onLoad',
|
|
12
|
+
'onView',
|
|
13
|
+
'onSave',
|
|
14
|
+
'onBeforeSave',
|
|
15
|
+
'onAfterSave'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function isPlainObject(value) {
|
|
19
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function appendError(errors, path, message) {
|
|
23
|
+
errors.push(`${path}: ${message}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validateHook(hook, path, errors) {
|
|
27
|
+
if (!isPlainObject(hook)) {
|
|
28
|
+
appendError(errors, path, 'hook must be an object');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof hook.name !== 'string' || hook.name.trim() === '') {
|
|
33
|
+
appendError(errors, `${path}.name`, 'must be a non-empty string');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!['client', 'server'].includes(hook.target)) {
|
|
37
|
+
appendError(errors, `${path}.target`, 'must be "client" or "server"');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (hook.target === 'client' && (typeof hook.topic !== 'string' || hook.topic.trim() === '')) {
|
|
41
|
+
appendError(errors, `${path}.topic`, 'is required for client hooks');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (hook.target === 'server') {
|
|
45
|
+
const hasAction = typeof hook.action === 'string' && hook.action.trim() !== '';
|
|
46
|
+
const hasEndpoint = typeof hook.endpoint === 'string' && hook.endpoint.trim() !== '';
|
|
47
|
+
if (!hasAction && !hasEndpoint) {
|
|
48
|
+
appendError(errors, path, 'server hooks require either action or endpoint');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateEvents(events, path, errors) {
|
|
54
|
+
if (!isPlainObject(events)) {
|
|
55
|
+
appendError(errors, path, 'must be an object');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const [eventName, hooks] of Object.entries(events)) {
|
|
60
|
+
if (!LIFECYCLE_EVENTS.includes(eventName)) {
|
|
61
|
+
appendError(errors, `${path}.${eventName}`, `is not supported (allowed: ${LIFECYCLE_EVENTS.join(', ')})`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(hooks)) {
|
|
65
|
+
appendError(errors, `${path}.${eventName}`, 'must be an array of hooks');
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
hooks.forEach((hook, index) => validateHook(hook, `${path}.${eventName}[${index}]`, errors));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateNavItem(item, path, errors) {
|
|
73
|
+
if (!isPlainObject(item)) {
|
|
74
|
+
appendError(errors, path, 'must be an object');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof item.id !== 'string' || item.id.trim() === '') {
|
|
79
|
+
appendError(errors, `${path}.id`, 'must be a non-empty string');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof item.label !== 'string' || item.label.trim() === '') {
|
|
83
|
+
appendError(errors, `${path}.label`, 'must be a non-empty string');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (item.componentId != null && (typeof item.componentId !== 'string' || item.componentId.trim() === '')) {
|
|
87
|
+
appendError(errors, `${path}.componentId`, 'must be a non-empty string when provided');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
|
91
|
+
if (!item.componentId && !hasChildren) {
|
|
92
|
+
appendError(errors, path, 'must define componentId or non-empty children');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (item.children != null) {
|
|
96
|
+
if (!Array.isArray(item.children)) {
|
|
97
|
+
appendError(errors, `${path}.children`, 'must be an array');
|
|
98
|
+
} else {
|
|
99
|
+
item.children.forEach((child, index) => validateNavItem(child, `${path}.children[${index}]`, errors));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (item.events != null) {
|
|
104
|
+
validateEvents(item.events, `${path}.events`, errors);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateComponent(component, path, errors) {
|
|
109
|
+
if (!isPlainObject(component)) {
|
|
110
|
+
appendError(errors, path, 'must be an object');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof component.id !== 'string' || component.id.trim() === '') {
|
|
115
|
+
appendError(errors, `${path}.id`, 'must be a non-empty string');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof component.tagName !== 'string' || component.tagName.trim() === '') {
|
|
119
|
+
appendError(errors, `${path}.tagName`, 'must be a non-empty string');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (component.moduleUrl != null && typeof component.moduleUrl !== 'string') {
|
|
123
|
+
appendError(errors, `${path}.moduleUrl`, 'must be a string when provided');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function validateAppManifest(manifest) {
|
|
128
|
+
const errors = [];
|
|
129
|
+
|
|
130
|
+
if (!isPlainObject(manifest)) {
|
|
131
|
+
return {
|
|
132
|
+
valid: false,
|
|
133
|
+
errors: ['root: manifest must be an object']
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (String(manifest.schemaVersion || '') !== APP_MANIFEST_VERSION) {
|
|
138
|
+
appendError(errors, 'schemaVersion', `must equal "${APP_MANIFEST_VERSION}"`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isPlainObject(manifest.app)) {
|
|
142
|
+
appendError(errors, 'app', 'must be an object');
|
|
143
|
+
} else {
|
|
144
|
+
if (typeof manifest.app.id !== 'string' || manifest.app.id.trim() === '') {
|
|
145
|
+
appendError(errors, 'app.id', 'must be a non-empty string');
|
|
146
|
+
}
|
|
147
|
+
if (typeof manifest.app.name !== 'string' || manifest.app.name.trim() === '') {
|
|
148
|
+
appendError(errors, 'app.name', 'must be a non-empty string');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!Array.isArray(manifest.navigation) || manifest.navigation.length === 0) {
|
|
153
|
+
appendError(errors, 'navigation', 'must be a non-empty array');
|
|
154
|
+
} else {
|
|
155
|
+
manifest.navigation.forEach((item, index) => validateNavItem(item, `navigation[${index}]`, errors));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (manifest.components != null) {
|
|
159
|
+
if (!Array.isArray(manifest.components)) {
|
|
160
|
+
appendError(errors, 'components', 'must be an array when provided');
|
|
161
|
+
} else {
|
|
162
|
+
manifest.components.forEach((component, index) => validateComponent(component, `components[${index}]`, errors));
|
|
163
|
+
const seenComponentIds = new Set();
|
|
164
|
+
manifest.components.forEach((component, index) => {
|
|
165
|
+
if (!component || typeof component.id !== 'string') return;
|
|
166
|
+
if (seenComponentIds.has(component.id)) {
|
|
167
|
+
appendError(errors, `components[${index}].id`, `duplicate id "${component.id}"`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
seenComponentIds.add(component.id);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (manifest.events != null) {
|
|
176
|
+
validateEvents(manifest.events, 'events', errors);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (manifest.integrations != null) {
|
|
180
|
+
if (!isPlainObject(manifest.integrations)) {
|
|
181
|
+
appendError(errors, 'integrations', 'must be an object');
|
|
182
|
+
} else {
|
|
183
|
+
if (manifest.integrations.serverBaseUrl != null && typeof manifest.integrations.serverBaseUrl !== 'string') {
|
|
184
|
+
appendError(errors, 'integrations.serverBaseUrl', 'must be a string when provided');
|
|
185
|
+
}
|
|
186
|
+
if (manifest.integrations.allowedActions != null) {
|
|
187
|
+
if (!Array.isArray(manifest.integrations.allowedActions)) {
|
|
188
|
+
appendError(errors, 'integrations.allowedActions', 'must be an array when provided');
|
|
189
|
+
} else {
|
|
190
|
+
manifest.integrations.allowedActions.forEach((action, index) => {
|
|
191
|
+
if (typeof action !== 'string' || action.trim() === '') {
|
|
192
|
+
appendError(errors, `integrations.allowedActions[${index}]`, 'must be a non-empty string');
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
valid: errors.length === 0,
|
|
202
|
+
errors
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function assertValidAppManifest(manifest) {
|
|
207
|
+
const result = validateAppManifest(manifest);
|
|
208
|
+
if (!result.valid) {
|
|
209
|
+
throw new Error(`Invalid app manifest:\n- ${result.errors.join('\n- ')}`);
|
|
210
|
+
}
|
|
211
|
+
return manifest;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
APP_MANIFEST_VERSION,
|
|
216
|
+
LIFECYCLE_EVENTS,
|
|
217
|
+
validateAppManifest,
|
|
218
|
+
assertValidAppManifest
|
|
219
|
+
};
|
|
220
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
(function initTopicsModule(root, factory) {
|
|
2
|
+
if (typeof module === 'object' && module.exports) {
|
|
3
|
+
module.exports = factory();
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
root.TextWebPanTopics = factory();
|
|
7
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this, function topicsFactory() {
|
|
8
|
+
const PAN_TOPICS = Object.freeze({
|
|
9
|
+
APP_NAV_OPEN: 'app.nav.open',
|
|
10
|
+
APP_TAB_OPEN: 'app.tab.open',
|
|
11
|
+
APP_VIEW_LOAD: 'app.view.load',
|
|
12
|
+
FORM_SAVE_REQUEST: 'form.save.request',
|
|
13
|
+
FORM_SAVE_SUCCESS: 'form.save.success',
|
|
14
|
+
FORM_SAVE_ERROR: 'form.save.error',
|
|
15
|
+
INTEGRATION_SERVER_REQUEST: 'integration.server.request',
|
|
16
|
+
INTEGRATION_SERVER_RESPONSE: 'integration.server.response'
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const EVENT_TO_TOPIC = Object.freeze({
|
|
20
|
+
onLoad: PAN_TOPICS.APP_VIEW_LOAD,
|
|
21
|
+
onView: PAN_TOPICS.APP_VIEW_LOAD,
|
|
22
|
+
onSave: PAN_TOPICS.FORM_SAVE_REQUEST,
|
|
23
|
+
onBeforeSave: PAN_TOPICS.FORM_SAVE_REQUEST,
|
|
24
|
+
onAfterSave: PAN_TOPICS.FORM_SAVE_SUCCESS
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function allTopics() {
|
|
28
|
+
return Object.values(PAN_TOPICS);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isKnownTopic(topic) {
|
|
32
|
+
return allTopics().includes(topic);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function topicForLifecycleEvent(eventName) {
|
|
36
|
+
return EVENT_TO_TOPIC[eventName] || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
PAN_TOPICS,
|
|
41
|
+
EVENT_TO_TOPIC,
|
|
42
|
+
allTopics,
|
|
43
|
+
isKnownTopic,
|
|
44
|
+
topicForLifecycleEvent
|
|
45
|
+
};
|
|
46
|
+
});
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const { AgentBrowser } = require('./browser');
|
|
8
8
|
const { createServer } = require('./server');
|
|
9
|
+
const { ensureBrowser } = require('./ensure-browser');
|
|
9
10
|
const readline = require('readline');
|
|
10
11
|
|
|
11
12
|
// Parse command line arguments
|
|
@@ -15,6 +16,7 @@ function parseArgs() {
|
|
|
15
16
|
url: null,
|
|
16
17
|
interactive: false,
|
|
17
18
|
json: false,
|
|
19
|
+
output: 'grid',
|
|
18
20
|
serve: false,
|
|
19
21
|
cols: 100,
|
|
20
22
|
port: 3000,
|
|
@@ -33,7 +35,21 @@ function parseArgs() {
|
|
|
33
35
|
case '--json':
|
|
34
36
|
case '-j':
|
|
35
37
|
options.json = true;
|
|
38
|
+
options.output = 'json';
|
|
36
39
|
break;
|
|
40
|
+
|
|
41
|
+
case '--output':
|
|
42
|
+
case '-o': {
|
|
43
|
+
const value = (args[++i] || '').toLowerCase();
|
|
44
|
+
if (['grid', 'semantic', 'hybrid', 'json'].includes(value)) {
|
|
45
|
+
options.output = value;
|
|
46
|
+
options.json = value === 'json';
|
|
47
|
+
} else {
|
|
48
|
+
options.help = true;
|
|
49
|
+
options.error = `Invalid --output value "${value}". Expected one of: grid, semantic, hybrid, json`;
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
37
53
|
|
|
38
54
|
case '--serve':
|
|
39
55
|
case '-s':
|
|
@@ -81,7 +97,8 @@ TextWeb - Text-grid web renderer for AI agents
|
|
|
81
97
|
USAGE:
|
|
82
98
|
textweb <url> Render page and print to console
|
|
83
99
|
textweb --interactive <url> Start interactive REPL mode
|
|
84
|
-
textweb --json <url> Output as JSON (view + elements)
|
|
100
|
+
textweb --json <url> Output as JSON (legacy: view + elements + meta)
|
|
101
|
+
textweb --output <mode> <url> Choose output mode (grid|semantic|hybrid|json)
|
|
85
102
|
textweb --serve Start HTTP API server
|
|
86
103
|
|
|
87
104
|
OPTIONS:
|
|
@@ -90,12 +107,15 @@ OPTIONS:
|
|
|
90
107
|
--port, -p <number> Server port (default: 3000)
|
|
91
108
|
--interactive, -i Interactive REPL mode
|
|
92
109
|
--json, -j JSON output format
|
|
110
|
+
--output, -o <mode> Output mode: grid, semantic, hybrid, json
|
|
93
111
|
--serve, -s Start HTTP server
|
|
94
112
|
--help, -h Show this help message
|
|
95
113
|
|
|
96
114
|
EXAMPLES:
|
|
97
115
|
textweb https://example.com
|
|
98
116
|
textweb --interactive https://github.com
|
|
117
|
+
textweb --output semantic https://news.ycombinator.com
|
|
118
|
+
textweb --output hybrid --cols 120 https://news.ycombinator.com
|
|
99
119
|
textweb --json --cols 120 https://news.ycombinator.com
|
|
100
120
|
textweb --serve --port 8080
|
|
101
121
|
|
|
@@ -125,8 +145,22 @@ async function render(url, options) {
|
|
|
125
145
|
try {
|
|
126
146
|
console.error(`Rendering: ${url}`);
|
|
127
147
|
const result = await browser.navigate(url);
|
|
128
|
-
|
|
129
|
-
if (options.
|
|
148
|
+
|
|
149
|
+
if (options.output === 'semantic') {
|
|
150
|
+
console.log(JSON.stringify(result.semantic || {
|
|
151
|
+
mode: 'semantic',
|
|
152
|
+
url: result.meta?.url || null,
|
|
153
|
+
title: result.meta?.title || null,
|
|
154
|
+
elements: [],
|
|
155
|
+
}, null, 2));
|
|
156
|
+
} else if (options.output === 'hybrid') {
|
|
157
|
+
console.log(JSON.stringify({
|
|
158
|
+
mode: 'hybrid',
|
|
159
|
+
view: result.view,
|
|
160
|
+
semantic: result.semantic || { mode: 'semantic', elements: [] },
|
|
161
|
+
meta: result.meta,
|
|
162
|
+
}, null, 2));
|
|
163
|
+
} else if (options.json || options.output === 'json') {
|
|
130
164
|
console.log(JSON.stringify({
|
|
131
165
|
view: result.view,
|
|
132
166
|
elements: result.elements,
|
|
@@ -379,6 +413,7 @@ async function serve(options) {
|
|
|
379
413
|
console.log(` POST /type - Type text`);
|
|
380
414
|
console.log(` POST /scroll - Scroll page`);
|
|
381
415
|
console.log(` POST /select - Select dropdown option`);
|
|
416
|
+
console.log(` POST /integrations/:action - Runtime integration hook actions`);
|
|
382
417
|
console.log(` GET /snapshot - Get current state`);
|
|
383
418
|
console.log(` GET /health - Health check`);
|
|
384
419
|
});
|
|
@@ -387,12 +422,19 @@ async function serve(options) {
|
|
|
387
422
|
// Main entry point
|
|
388
423
|
async function main() {
|
|
389
424
|
const options = parseArgs();
|
|
425
|
+
if (options.error) {
|
|
426
|
+
console.error(`Error: ${options.error}`);
|
|
427
|
+
showHelp();
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
390
430
|
|
|
391
431
|
if (options.help || (process.argv.length === 2)) {
|
|
392
432
|
showHelp();
|
|
393
433
|
return;
|
|
394
434
|
}
|
|
395
|
-
|
|
435
|
+
|
|
436
|
+
await ensureBrowser();
|
|
437
|
+
|
|
396
438
|
if (options.serve) {
|
|
397
439
|
await serve(options);
|
|
398
440
|
} else if (options.interactive) {
|
|
@@ -425,4 +467,4 @@ if (require.main === module) {
|
|
|
425
467
|
});
|
|
426
468
|
}
|
|
427
469
|
|
|
428
|
-
module.exports = { parseArgs, render, interactive, serve };
|
|
470
|
+
module.exports = { parseArgs, render, interactive, serve };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ensure-browser.js
|
|
5
|
+
*
|
|
6
|
+
* Checks whether Playwright's Chromium is installed and, when running
|
|
7
|
+
* in an interactive terminal, offers to install it automatically.
|
|
8
|
+
*
|
|
9
|
+
* All output goes to stderr so it never pollutes stdout-based protocols
|
|
10
|
+
* (e.g. the MCP JSON-RPC transport).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
const readline = require('readline');
|
|
16
|
+
|
|
17
|
+
function ask(prompt) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
20
|
+
rl.question(prompt, (answer) => {
|
|
21
|
+
rl.close();
|
|
22
|
+
resolve(answer.trim());
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function log(msg) {
|
|
28
|
+
process.stderr.write(msg + '\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isInstalled() {
|
|
32
|
+
try {
|
|
33
|
+
const { chromium } = require('playwright');
|
|
34
|
+
const execPath = chromium.executablePath();
|
|
35
|
+
return fs.existsSync(execPath);
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function install() {
|
|
42
|
+
// Prefer the local playwright CLI (bundled with the dep) over npx
|
|
43
|
+
let cliPath = null;
|
|
44
|
+
try {
|
|
45
|
+
cliPath = require.resolve('playwright/cli.js');
|
|
46
|
+
} catch { /* fall through to npx */ }
|
|
47
|
+
|
|
48
|
+
const result = cliPath
|
|
49
|
+
? spawnSync(process.execPath, [cliPath, 'install', 'chromium'], { stdio: 'inherit' })
|
|
50
|
+
: spawnSync('npx', ['playwright', 'install', 'chromium'], { stdio: 'inherit', shell: true });
|
|
51
|
+
|
|
52
|
+
return result.status === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function ensureBrowser() {
|
|
56
|
+
if (isInstalled()) return;
|
|
57
|
+
|
|
58
|
+
log('');
|
|
59
|
+
log('⚠ Playwright Chromium is not installed — textweb needs it to browse the web.');
|
|
60
|
+
|
|
61
|
+
if (process.stdin.isTTY) {
|
|
62
|
+
const answer = await ask(' Install it now? (Y/n) ');
|
|
63
|
+
if (answer.toLowerCase() === 'n') {
|
|
64
|
+
log('');
|
|
65
|
+
log(' Run manually: npx playwright install chromium');
|
|
66
|
+
log('');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
log('');
|
|
71
|
+
log(' Run: npx playwright install chromium');
|
|
72
|
+
log('');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
log('');
|
|
77
|
+
log(' Installing Chromium — this takes ~30 seconds the first time…');
|
|
78
|
+
log('');
|
|
79
|
+
|
|
80
|
+
if (!install()) {
|
|
81
|
+
log('');
|
|
82
|
+
log(' Installation failed. Run manually: npx playwright install chromium');
|
|
83
|
+
log('');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
log('');
|
|
88
|
+
log('✓ Chromium installed successfully!');
|
|
89
|
+
log('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { ensureBrowser };
|
package/src/renderer.js
CHANGED
|
@@ -56,6 +56,7 @@ async function measureCharSize(page) {
|
|
|
56
56
|
async function extractElements(page) {
|
|
57
57
|
return await page.evaluate(() => {
|
|
58
58
|
const results = [];
|
|
59
|
+
const interactiveSelector = 'a[href], button, input, select, textarea, [onclick], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"]), summary';
|
|
59
60
|
|
|
60
61
|
function isVisible(el) {
|
|
61
62
|
const style = getComputedStyle(el);
|
|
@@ -131,7 +132,24 @@ async function extractElements(page) {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
function isInteractive(el) {
|
|
134
|
-
return el.matches(
|
|
135
|
+
return el.matches(interactiveSelector);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function domPath(el) {
|
|
139
|
+
const parts = [];
|
|
140
|
+
let current = el;
|
|
141
|
+
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
|
|
142
|
+
const tag = current.tagName.toLowerCase();
|
|
143
|
+
let idx = 1;
|
|
144
|
+
let sib = current;
|
|
145
|
+
while ((sib = sib.previousElementSibling)) {
|
|
146
|
+
if (sib.tagName.toLowerCase() === tag) idx++;
|
|
147
|
+
}
|
|
148
|
+
parts.push(`${tag}:${idx}`);
|
|
149
|
+
current = current.parentElement;
|
|
150
|
+
}
|
|
151
|
+
parts.push('body:1');
|
|
152
|
+
return parts.reverse().join('/');
|
|
135
153
|
}
|
|
136
154
|
|
|
137
155
|
// Detect tables and extract their structure
|
|
@@ -203,18 +221,23 @@ async function extractElements(page) {
|
|
|
203
221
|
|
|
204
222
|
const tag = el.tagName.toLowerCase();
|
|
205
223
|
const interactive = isInteractive(el);
|
|
224
|
+
const role = el.getAttribute('role') || null;
|
|
206
225
|
|
|
207
226
|
let text = '';
|
|
227
|
+
let value = null;
|
|
208
228
|
if (isText) {
|
|
209
229
|
text = node.textContent.trim();
|
|
210
230
|
} else if (tag === 'input') {
|
|
211
231
|
const type = (el.type || 'text').toLowerCase();
|
|
212
232
|
text = el.value || el.placeholder || '';
|
|
233
|
+
value = el.value || '';
|
|
213
234
|
} else if (tag === 'select') {
|
|
214
235
|
const opt = el.options && el.options[el.selectedIndex];
|
|
215
236
|
text = opt ? opt.text : '';
|
|
237
|
+
value = el.value || '';
|
|
216
238
|
} else if (tag === 'textarea') {
|
|
217
239
|
text = el.value || el.placeholder || '';
|
|
240
|
+
value = el.value || '';
|
|
218
241
|
} else if (tag === 'img') {
|
|
219
242
|
text = el.alt || '[img]';
|
|
220
243
|
} else if (tag === 'hr') {
|
|
@@ -286,14 +309,28 @@ async function extractElements(page) {
|
|
|
286
309
|
}
|
|
287
310
|
}
|
|
288
311
|
|
|
312
|
+
const parentElement = el.parentElement;
|
|
313
|
+
const parentInteractive = !!(parentElement && parentElement.matches(interactiveSelector));
|
|
314
|
+
const parentPath = parentElement ? domPath(parentElement) : null;
|
|
315
|
+
|
|
289
316
|
results.push({
|
|
290
317
|
text,
|
|
291
318
|
label: label || '',
|
|
319
|
+
role,
|
|
292
320
|
tag,
|
|
293
321
|
semantic,
|
|
294
322
|
headingLevel: headingMatch ? parseInt(headingMatch[1]) : 0,
|
|
295
323
|
interactive,
|
|
324
|
+
isTextNode: isText,
|
|
296
325
|
checked: !!el.checked,
|
|
326
|
+
selected: !!el.selected,
|
|
327
|
+
disabled: !!el.disabled,
|
|
328
|
+
required: !!el.required,
|
|
329
|
+
expanded: el.getAttribute('aria-expanded') === 'true',
|
|
330
|
+
placeholder: el.getAttribute('placeholder') || null,
|
|
331
|
+
name: el.getAttribute('name') || '',
|
|
332
|
+
alt: el.getAttribute('alt') || '',
|
|
333
|
+
value,
|
|
297
334
|
x: rect.x,
|
|
298
335
|
y: rect.y,
|
|
299
336
|
w: rect.width,
|
|
@@ -301,6 +338,9 @@ async function extractElements(page) {
|
|
|
301
338
|
z: getZIndex(el),
|
|
302
339
|
href: el.href || null,
|
|
303
340
|
selector: buildSelector(el),
|
|
341
|
+
domPath: domPath(el),
|
|
342
|
+
parentPath,
|
|
343
|
+
parentInteractive,
|
|
304
344
|
tableCell,
|
|
305
345
|
});
|
|
306
346
|
}
|
|
@@ -311,6 +351,103 @@ async function extractElements(page) {
|
|
|
311
351
|
});
|
|
312
352
|
}
|
|
313
353
|
|
|
354
|
+
function stableHash(input) {
|
|
355
|
+
let hash = 5381;
|
|
356
|
+
for (let i = 0; i < input.length; i++) {
|
|
357
|
+
hash = ((hash << 5) + hash) + input.charCodeAt(i);
|
|
358
|
+
hash |= 0;
|
|
359
|
+
}
|
|
360
|
+
return (hash >>> 0).toString(36);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function buildSemanticModel(rawElements, layoutEntries, pageMeta) {
|
|
364
|
+
const layoutByDomPath = new Map();
|
|
365
|
+
for (const item of layoutEntries || []) {
|
|
366
|
+
const key = item.domPath || `${item.selector}|${item.x}|${item.y}`;
|
|
367
|
+
layoutByDomPath.set(key, item);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const elements = [];
|
|
371
|
+
const byPath = new Map();
|
|
372
|
+
const rawByPath = new Map();
|
|
373
|
+
const identityCounts = new Map();
|
|
374
|
+
|
|
375
|
+
for (let i = 0; i < rawElements.length; i++) {
|
|
376
|
+
const el = rawElements[i];
|
|
377
|
+
rawByPath.set(el.domPath, el);
|
|
378
|
+
const name = (el.label || el.text || el.alt || el.name || '').trim();
|
|
379
|
+
const baseSeed = [
|
|
380
|
+
el.semantic || 'unknown',
|
|
381
|
+
el.role || '',
|
|
382
|
+
name.toLowerCase(),
|
|
383
|
+
el.parentPath || '',
|
|
384
|
+
el.domPath || '',
|
|
385
|
+
].join('|');
|
|
386
|
+
const ordinal = identityCounts.get(baseSeed) || 0;
|
|
387
|
+
identityCounts.set(baseSeed, ordinal + 1);
|
|
388
|
+
const id = `e_${stableHash(`${baseSeed}|${ordinal}`).slice(0, 8)}`;
|
|
389
|
+
|
|
390
|
+
const layoutKey = el.domPath || `${el.selector}|${el.x}|${el.y}`;
|
|
391
|
+
const layout = layoutByDomPath.get(layoutKey);
|
|
392
|
+
const semanticEl = {
|
|
393
|
+
id,
|
|
394
|
+
type: el.semantic || 'text',
|
|
395
|
+
role: el.role || null,
|
|
396
|
+
name: name || null,
|
|
397
|
+
text: el.text || null,
|
|
398
|
+
value: el.value ?? null,
|
|
399
|
+
href: el.href || null,
|
|
400
|
+
placeholder: el.placeholder || null,
|
|
401
|
+
checked: typeof el.checked === 'boolean' ? el.checked : null,
|
|
402
|
+
selected: typeof el.selected === 'boolean' ? el.selected : null,
|
|
403
|
+
disabled: !!el.disabled,
|
|
404
|
+
required: !!el.required,
|
|
405
|
+
expanded: !!el.expanded,
|
|
406
|
+
visible: true,
|
|
407
|
+
parent_id: null,
|
|
408
|
+
children: [],
|
|
409
|
+
grid_ref: layout && layout.ref !== null ? layout.ref : null,
|
|
410
|
+
grid_bounds: layout ? {
|
|
411
|
+
row: layout.row,
|
|
412
|
+
col_start: layout.colStart,
|
|
413
|
+
col_end: layout.colEnd,
|
|
414
|
+
} : null,
|
|
415
|
+
selector: el.selector,
|
|
416
|
+
path: el.domPath,
|
|
417
|
+
// Future hooks: action routing and structural diff matching.
|
|
418
|
+
actions: el.interactive ? ['click'] : [],
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
if (semanticEl.type === 'input' || semanticEl.type === 'textarea' || semanticEl.type === 'select') {
|
|
422
|
+
semanticEl.actions.push('type');
|
|
423
|
+
}
|
|
424
|
+
if (semanticEl.type === 'select') {
|
|
425
|
+
semanticEl.actions.push('select');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
byPath.set(el.domPath, semanticEl);
|
|
429
|
+
elements.push(semanticEl);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
for (const el of elements) {
|
|
433
|
+
const raw = rawByPath.get(el.path);
|
|
434
|
+
const parentPath = raw ? raw.parentPath : null;
|
|
435
|
+
if (!parentPath) continue;
|
|
436
|
+
const parent = byPath.get(parentPath);
|
|
437
|
+
if (parent) {
|
|
438
|
+
el.parent_id = parent.id;
|
|
439
|
+
parent.children.push(el.id);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
mode: 'semantic',
|
|
445
|
+
url: pageMeta.url || null,
|
|
446
|
+
title: pageMeta.title || null,
|
|
447
|
+
elements,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
314
451
|
/**
|
|
315
452
|
* Detect row boundaries — groups of elements that share the same Y position
|
|
316
453
|
* This prevents text from different elements on the same visual line from overlapping
|
|
@@ -384,10 +521,12 @@ function formatElement(el, ref, cols, startCol, charW) {
|
|
|
384
521
|
* 3. Each visual row maps to one or more grid lines
|
|
385
522
|
* 4. Grid grows as needed (overflow — never lose data)
|
|
386
523
|
*/
|
|
387
|
-
function renderGrid(elements, cols, charW, charH, scrollY = 0) {
|
|
524
|
+
function renderGrid(elements, cols, charW, charH, scrollY = 0, options = {}) {
|
|
525
|
+
const { includeLayout = false } = options;
|
|
388
526
|
const elementMap = {};
|
|
389
527
|
let refId = 0;
|
|
390
528
|
const lines = []; // output lines as strings
|
|
529
|
+
const layout = [];
|
|
391
530
|
|
|
392
531
|
// Filter to viewport (vertically — allow overflow below)
|
|
393
532
|
const visible = elements.filter(el => {
|
|
@@ -399,6 +538,7 @@ function renderGrid(elements, cols, charW, charH, scrollY = 0) {
|
|
|
399
538
|
const visualRows = groupByRows(visible, charH);
|
|
400
539
|
|
|
401
540
|
for (const rowElements of visualRows) {
|
|
541
|
+
const rowIndex = lines.length;
|
|
402
542
|
// Sort elements in this row by X position (left to right)
|
|
403
543
|
rowElements.sort((a, b) => a.x - b.x);
|
|
404
544
|
|
|
@@ -428,18 +568,35 @@ function renderGrid(elements, cols, charW, charH, scrollY = 0) {
|
|
|
428
568
|
const display = formatElement(el, ref, cols, targetCol, charW);
|
|
429
569
|
if (!display) continue;
|
|
430
570
|
|
|
571
|
+
let startCol = cursor;
|
|
431
572
|
if (targetCol > cursor) {
|
|
432
573
|
// Pad with spaces to reach the target column
|
|
433
574
|
line += ' '.repeat(targetCol - cursor);
|
|
434
575
|
cursor = targetCol;
|
|
576
|
+
startCol = cursor;
|
|
435
577
|
} else if (cursor > 0 && targetCol <= cursor) {
|
|
436
578
|
// Elements overlap — add a single space separator
|
|
437
579
|
line += ' ';
|
|
438
580
|
cursor += 1;
|
|
581
|
+
startCol = cursor;
|
|
439
582
|
}
|
|
440
583
|
|
|
441
584
|
line += display;
|
|
442
585
|
cursor += display.length;
|
|
586
|
+
|
|
587
|
+
if (includeLayout) {
|
|
588
|
+
layout.push({
|
|
589
|
+
ref,
|
|
590
|
+
row: rowIndex,
|
|
591
|
+
colStart: startCol,
|
|
592
|
+
colEnd: cursor - 1,
|
|
593
|
+
selector: el.selector,
|
|
594
|
+
domPath: el.domPath,
|
|
595
|
+
semantic: el.semantic,
|
|
596
|
+
x: el.x,
|
|
597
|
+
y: el.y,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
443
600
|
}
|
|
444
601
|
|
|
445
602
|
lines.push(line.trimEnd());
|
|
@@ -448,11 +605,13 @@ function renderGrid(elements, cols, charW, charH, scrollY = 0) {
|
|
|
448
605
|
// Remove trailing empty lines
|
|
449
606
|
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
450
607
|
|
|
451
|
-
|
|
608
|
+
const result = {
|
|
452
609
|
view: lines.join('\n'),
|
|
453
610
|
elements: elementMap,
|
|
454
611
|
meta: { cols, rows: lines.length, scrollY, totalRefs: refId, charW, charH }
|
|
455
612
|
};
|
|
613
|
+
if (includeLayout) result.layout = layout;
|
|
614
|
+
return result;
|
|
456
615
|
}
|
|
457
616
|
|
|
458
617
|
/**
|
|
@@ -468,7 +627,12 @@ async function render(page, options = {}) {
|
|
|
468
627
|
const charH = metrics.charH;
|
|
469
628
|
|
|
470
629
|
const elements = await extractElements(page);
|
|
471
|
-
const
|
|
630
|
+
const gridResult = renderGrid(elements, cols, charW, charH, scrollY, { includeLayout: true });
|
|
631
|
+
const result = {
|
|
632
|
+
view: gridResult.view,
|
|
633
|
+
elements: gridResult.elements,
|
|
634
|
+
meta: gridResult.meta,
|
|
635
|
+
};
|
|
472
636
|
|
|
473
637
|
// Add stats to meta
|
|
474
638
|
result.meta.stats = {
|
|
@@ -476,8 +640,10 @@ async function render(page, options = {}) {
|
|
|
476
640
|
interactiveElements: result.meta.totalRefs,
|
|
477
641
|
renderMs: Date.now() - startMs,
|
|
478
642
|
};
|
|
643
|
+
|
|
644
|
+
result.semantic = buildSemanticModel(elements, gridResult.layout || [], result.meta);
|
|
479
645
|
|
|
480
646
|
return result;
|
|
481
647
|
}
|
|
482
648
|
|
|
483
|
-
module.exports = { render, extractElements, renderGrid, measureCharSize };
|
|
649
|
+
module.exports = { render, extractElements, renderGrid, measureCharSize, buildSemanticModel };
|
package/src/server.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
const http = require('http');
|
|
6
6
|
const url = require('url');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs/promises');
|
|
7
9
|
const { AgentBrowser } = require('./browser');
|
|
10
|
+
const { assertValidAppManifest } = require('./app-runtime/manifest');
|
|
8
11
|
|
|
9
12
|
class TextWebServer {
|
|
10
13
|
constructor(options = {}) {
|
|
@@ -12,6 +15,7 @@ class TextWebServer {
|
|
|
12
15
|
cols: options.cols || 100,
|
|
13
16
|
// rows is deprecated — height is dynamic
|
|
14
17
|
timeout: options.timeout || 30000,
|
|
18
|
+
appRuntimeDir: options.appRuntimeDir || path.join(process.cwd(), 'canvas', 'app-runtime'),
|
|
15
19
|
...options
|
|
16
20
|
};
|
|
17
21
|
|
|
@@ -125,6 +129,18 @@ class TextWebServer {
|
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
try {
|
|
132
|
+
if (method === 'GET' && this.isAppRuntimePath(pathname)) {
|
|
133
|
+
return await this.handleAppRuntimeStatic(req, res, pathname);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (pathname.startsWith('/integrations/')) {
|
|
137
|
+
if (method === 'POST') {
|
|
138
|
+
const action = decodeURIComponent(pathname.replace('/integrations/', '') || '');
|
|
139
|
+
return await this.handleIntegrationAction(req, res, action);
|
|
140
|
+
}
|
|
141
|
+
return this.sendError(res, `Method ${method} not allowed for ${pathname}`, 405);
|
|
142
|
+
}
|
|
143
|
+
|
|
128
144
|
switch (pathname) {
|
|
129
145
|
case '/health':
|
|
130
146
|
return this.handleHealth(req, res);
|
|
@@ -362,14 +378,15 @@ class TextWebServer {
|
|
|
362
378
|
if (typeof body.ref !== 'number') {
|
|
363
379
|
return this.sendError(res, 'Element reference (ref) is required');
|
|
364
380
|
}
|
|
365
|
-
|
|
366
|
-
|
|
381
|
+
const filePaths = body.files ?? body.path;
|
|
382
|
+
if (!filePaths) {
|
|
383
|
+
return this.sendError(res, 'files or path (string or array of file paths) is required');
|
|
367
384
|
}
|
|
368
385
|
if (!this.browser) {
|
|
369
386
|
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
370
387
|
}
|
|
371
388
|
|
|
372
|
-
const result = await this.browser.upload(body.ref,
|
|
389
|
+
const result = await this.browser.upload(body.ref, filePaths);
|
|
373
390
|
|
|
374
391
|
this.sendJSON(res, {
|
|
375
392
|
success: true,
|
|
@@ -470,6 +487,198 @@ class TextWebServer {
|
|
|
470
487
|
res.end(screenshot);
|
|
471
488
|
}
|
|
472
489
|
|
|
490
|
+
isAppRuntimePath(pathname) {
|
|
491
|
+
return pathname === '/' || pathname === '/app-runtime' || pathname.startsWith('/app-runtime/');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
contentTypeFor(filePath) {
|
|
495
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
496
|
+
if (ext === '.html') return 'text/html; charset=utf-8';
|
|
497
|
+
if (ext === '.js' || ext === '.mjs') return 'application/javascript; charset=utf-8';
|
|
498
|
+
if (ext === '.json') return 'application/json; charset=utf-8';
|
|
499
|
+
if (ext === '.css') return 'text/css; charset=utf-8';
|
|
500
|
+
if (ext === '.svg') return 'image/svg+xml';
|
|
501
|
+
return 'text/plain; charset=utf-8';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async handleAppRuntimeStatic(req, res, pathname) {
|
|
505
|
+
let relative = pathname;
|
|
506
|
+
if (relative === '/') {
|
|
507
|
+
relative = '/app-runtime/app-shell.html';
|
|
508
|
+
} else if (relative === '/app-runtime') {
|
|
509
|
+
relative = '/app-runtime/app-shell.html';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const runtimeRoot = path.resolve(this.options.appRuntimeDir);
|
|
513
|
+
const runtimeRelative = relative.replace(/^\/app-runtime\/?/, '');
|
|
514
|
+
const candidate = path.resolve(path.join(runtimeRoot, runtimeRelative));
|
|
515
|
+
if (!candidate.startsWith(runtimeRoot)) {
|
|
516
|
+
return this.sendError(res, 'Not found', 404);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const data = await fs.readFile(candidate);
|
|
521
|
+
res.writeHead(200, {
|
|
522
|
+
'Content-Type': this.contentTypeFor(candidate),
|
|
523
|
+
'Access-Control-Allow-Origin': '*'
|
|
524
|
+
});
|
|
525
|
+
res.end(data);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
if (error && error.code === 'ENOENT') {
|
|
528
|
+
return this.sendError(res, 'Not found', 404);
|
|
529
|
+
}
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
runtimePath(...segments) {
|
|
535
|
+
return path.join(this.options.appRuntimeDir, ...segments);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
sanitizeFileName(name, fallback = 'app.manifest.json') {
|
|
539
|
+
const candidate = String(name || '').trim();
|
|
540
|
+
const safe = candidate
|
|
541
|
+
.replace(/[^a-zA-Z0-9._-]/g, '-')
|
|
542
|
+
.replace(/-+/g, '-')
|
|
543
|
+
.replace(/^\.+/, '');
|
|
544
|
+
if (!safe) return fallback;
|
|
545
|
+
return safe.endsWith('.json') ? safe : `${safe}.json`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async readJSON(filePath, defaultValue) {
|
|
549
|
+
try {
|
|
550
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
551
|
+
return JSON.parse(raw);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (error && error.code === 'ENOENT') {
|
|
554
|
+
return defaultValue;
|
|
555
|
+
}
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async writeJSON(filePath, value) {
|
|
561
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
562
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
findNavItemById(items, id) {
|
|
566
|
+
for (const item of items) {
|
|
567
|
+
if (item.id === id) return item;
|
|
568
|
+
if (Array.isArray(item.children)) {
|
|
569
|
+
const child = this.findNavItemById(item.children, id);
|
|
570
|
+
if (child) return child;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async handleIntegrationAction(req, res, action) {
|
|
577
|
+
const body = await this.parseBody(req);
|
|
578
|
+
const handlers = {
|
|
579
|
+
sync_saved_form: this.handleIntegrationSyncSavedForm.bind(this),
|
|
580
|
+
save_manifest: this.handleIntegrationSaveManifest.bind(this),
|
|
581
|
+
upsert_nav_item: this.handleIntegrationUpsertNavItem.bind(this),
|
|
582
|
+
runtime_state: this.handleIntegrationRuntimeState.bind(this)
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const handler = handlers[action];
|
|
586
|
+
if (!handler) {
|
|
587
|
+
return this.sendError(res, `Unknown integration action: ${action}`, 404);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const result = await handler(body);
|
|
591
|
+
this.sendJSON(res, {
|
|
592
|
+
success: true,
|
|
593
|
+
action,
|
|
594
|
+
result
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async handleIntegrationSyncSavedForm(body) {
|
|
599
|
+
const componentId = String(body.componentId || '').trim();
|
|
600
|
+
if (!componentId) {
|
|
601
|
+
throw new Error('componentId is required');
|
|
602
|
+
}
|
|
603
|
+
if (!Array.isArray(body.schema)) {
|
|
604
|
+
throw new Error('schema must be an array');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const targetPath = this.runtimePath('generated-components.json');
|
|
608
|
+
const current = await this.readJSON(targetPath, []);
|
|
609
|
+
const next = current.filter(entry => entry.id !== componentId);
|
|
610
|
+
next.push({
|
|
611
|
+
id: componentId,
|
|
612
|
+
label: String(body.label || componentId),
|
|
613
|
+
schema: body.schema,
|
|
614
|
+
updatedAt: new Date().toISOString()
|
|
615
|
+
});
|
|
616
|
+
await this.writeJSON(targetPath, next);
|
|
617
|
+
return { saved: true, path: targetPath, total: next.length };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async handleIntegrationSaveManifest(body) {
|
|
621
|
+
const manifest = body.manifest;
|
|
622
|
+
assertValidAppManifest(manifest);
|
|
623
|
+
|
|
624
|
+
const fileName = this.sanitizeFileName(body.fileName, `${manifest.app.id}.json`);
|
|
625
|
+
const targetPath = this.runtimePath('manifests', fileName);
|
|
626
|
+
await this.writeJSON(targetPath, manifest);
|
|
627
|
+
return { saved: true, path: targetPath, fileName };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async handleIntegrationUpsertNavItem(body) {
|
|
631
|
+
const manifest = body.manifest;
|
|
632
|
+
const navItem = body.navItem;
|
|
633
|
+
const parentId = body.parentId ? String(body.parentId) : null;
|
|
634
|
+
|
|
635
|
+
assertValidAppManifest(manifest);
|
|
636
|
+
if (!navItem || typeof navItem !== 'object') {
|
|
637
|
+
throw new Error('navItem is required');
|
|
638
|
+
}
|
|
639
|
+
if (!navItem.id || !navItem.label || !navItem.componentId) {
|
|
640
|
+
throw new Error('navItem.id, navItem.label, and navItem.componentId are required');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const target = {
|
|
644
|
+
id: String(navItem.id),
|
|
645
|
+
label: String(navItem.label),
|
|
646
|
+
componentId: String(navItem.componentId),
|
|
647
|
+
events: navItem.events || undefined
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
let collection = manifest.navigation;
|
|
651
|
+
if (parentId) {
|
|
652
|
+
const parent = this.findNavItemById(manifest.navigation, parentId);
|
|
653
|
+
if (!parent) {
|
|
654
|
+
throw new Error(`parentId not found: ${parentId}`);
|
|
655
|
+
}
|
|
656
|
+
if (!Array.isArray(parent.children)) {
|
|
657
|
+
parent.children = [];
|
|
658
|
+
}
|
|
659
|
+
collection = parent.children;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const existingIndex = collection.findIndex(item => item.id === target.id);
|
|
663
|
+
if (existingIndex >= 0) {
|
|
664
|
+
collection[existingIndex] = { ...collection[existingIndex], ...target };
|
|
665
|
+
} else {
|
|
666
|
+
collection.push(target);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
assertValidAppManifest(manifest);
|
|
670
|
+
return { saved: true, navItemId: target.id, parentId };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async handleIntegrationRuntimeState() {
|
|
674
|
+
const componentPath = this.runtimePath('generated-components.json');
|
|
675
|
+
const components = await this.readJSON(componentPath, []);
|
|
676
|
+
return {
|
|
677
|
+
generatedComponentsPath: componentPath,
|
|
678
|
+
generatedComponents: components
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
473
682
|
/**
|
|
474
683
|
* Close browser
|
|
475
684
|
*/
|
|
@@ -500,4 +709,4 @@ function createServer(options = {}) {
|
|
|
500
709
|
});
|
|
501
710
|
}
|
|
502
711
|
|
|
503
|
-
module.exports = { createServer, TextWebServer };
|
|
712
|
+
module.exports = { createServer, TextWebServer };
|