textweb 0.2.4 → 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 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 for agents
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "textweb",
3
- "version": "0.2.4",
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
@@ -16,6 +16,7 @@ function parseArgs() {
16
16
  url: null,
17
17
  interactive: false,
18
18
  json: false,
19
+ output: 'grid',
19
20
  serve: false,
20
21
  cols: 100,
21
22
  port: 3000,
@@ -34,7 +35,21 @@ function parseArgs() {
34
35
  case '--json':
35
36
  case '-j':
36
37
  options.json = true;
38
+ options.output = 'json';
37
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
+ }
38
53
 
39
54
  case '--serve':
40
55
  case '-s':
@@ -82,7 +97,8 @@ TextWeb - Text-grid web renderer for AI agents
82
97
  USAGE:
83
98
  textweb <url> Render page and print to console
84
99
  textweb --interactive <url> Start interactive REPL mode
85
- 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)
86
102
  textweb --serve Start HTTP API server
87
103
 
88
104
  OPTIONS:
@@ -91,12 +107,15 @@ OPTIONS:
91
107
  --port, -p <number> Server port (default: 3000)
92
108
  --interactive, -i Interactive REPL mode
93
109
  --json, -j JSON output format
110
+ --output, -o <mode> Output mode: grid, semantic, hybrid, json
94
111
  --serve, -s Start HTTP server
95
112
  --help, -h Show this help message
96
113
 
97
114
  EXAMPLES:
98
115
  textweb https://example.com
99
116
  textweb --interactive https://github.com
117
+ textweb --output semantic https://news.ycombinator.com
118
+ textweb --output hybrid --cols 120 https://news.ycombinator.com
100
119
  textweb --json --cols 120 https://news.ycombinator.com
101
120
  textweb --serve --port 8080
102
121
 
@@ -126,8 +145,22 @@ async function render(url, options) {
126
145
  try {
127
146
  console.error(`Rendering: ${url}`);
128
147
  const result = await browser.navigate(url);
129
-
130
- if (options.json) {
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') {
131
164
  console.log(JSON.stringify({
132
165
  view: result.view,
133
166
  elements: result.elements,
@@ -380,6 +413,7 @@ async function serve(options) {
380
413
  console.log(` POST /type - Type text`);
381
414
  console.log(` POST /scroll - Scroll page`);
382
415
  console.log(` POST /select - Select dropdown option`);
416
+ console.log(` POST /integrations/:action - Runtime integration hook actions`);
383
417
  console.log(` GET /snapshot - Get current state`);
384
418
  console.log(` GET /health - Health check`);
385
419
  });
@@ -388,6 +422,11 @@ async function serve(options) {
388
422
  // Main entry point
389
423
  async function main() {
390
424
  const options = parseArgs();
425
+ if (options.error) {
426
+ console.error(`Error: ${options.error}`);
427
+ showHelp();
428
+ process.exit(1);
429
+ }
391
430
 
392
431
  if (options.help || (process.argv.length === 2)) {
393
432
  showHelp();
@@ -428,4 +467,4 @@ if (require.main === module) {
428
467
  });
429
468
  }
430
469
 
431
- module.exports = { parseArgs, render, interactive, serve };
470
+ module.exports = { parseArgs, render, interactive, serve };
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('a[href], button, input, select, textarea, [onclick], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"]), summary');
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
- return {
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 result = renderGrid(elements, cols, charW, charH, scrollY);
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
- if (!body.files) {
366
- return this.sendError(res, 'files (string or array of file paths) is required');
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, body.files);
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 };