@nxisai/nxis 2.1.2 → 2.2.2
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/.github/workflows/deploy.yml +45 -7
- package/README.md +288 -29
- package/build/api.d.ts +13 -2
- package/build/core.d.ts +1 -1
- package/build/nxis.js +1 -1
- package/build/webmcp/contract.d.ts +109 -0
- package/build/webmcp/execute.d.ts +24 -0
- package/build/webmcp/index.d.ts +37 -0
- package/build/webmcp/locate.d.ts +19 -0
- package/build/webmcp/parse.d.ts +20 -0
- package/build/webmcp/reconcile.d.ts +57 -0
- package/build/webmcp/register.d.ts +31 -0
- package/build/webmcp/schema.d.ts +30 -0
- package/build/webmcp/types.d.ts +72 -0
- package/build/webmcp.js +1 -0
- package/package.json +12 -2
- package/rollup.config.js +20 -1
- package/src/api.ts +13 -2
- package/src/client.ts +23 -13
- package/src/core.ts +1 -2
- package/src/webmcp/contract.ts +168 -0
- package/src/webmcp/execute.ts +514 -0
- package/src/webmcp/index.ts +51 -0
- package/src/webmcp/locate.ts +200 -0
- package/src/webmcp/parse.ts +276 -0
- package/src/webmcp/reconcile.ts +170 -0
- package/src/webmcp/register.ts +285 -0
- package/src/webmcp/schema.ts +201 -0
- package/src/webmcp/types.ts +86 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
*
|
|
4
|
+
* `nxis/webmcp` — WebMCP tool registration for NxisAI structured data.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* This subpath export reads Action + EntryPoint JSON-LD injected by the
|
|
8
|
+
* NxisAI backend and registers each Action as a WebMCP tool via
|
|
9
|
+
* `document.modelContext.registerTool`. Each tool's `execute` locates the
|
|
10
|
+
* real `<form>` on the live page and fills it.
|
|
11
|
+
*
|
|
12
|
+
* **Browser-only.** This module requires `document.modelContext` (Chrome 149+).
|
|
13
|
+
* It gracefully no-ops in environments where WebMCP is unavailable.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* import { registerActions } from 'nxis/webmcp';
|
|
18
|
+
*
|
|
19
|
+
* // Auto-register all Action tools found in the page's JSON-LD
|
|
20
|
+
* const cleanup = registerActions();
|
|
21
|
+
*
|
|
22
|
+
* // Later, to unregister all tools:
|
|
23
|
+
* cleanup();
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* // Opt-out of MutationObserver re-scan for SPAs
|
|
29
|
+
* const cleanup = registerActions({ observe: false });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export { registerActions } from './register';
|
|
34
|
+
export type { RegisterOptions } from './register';
|
|
35
|
+
|
|
36
|
+
// Re-export contract types for consumers who need them
|
|
37
|
+
export type {
|
|
38
|
+
NxisAction,
|
|
39
|
+
NxisEntryPoint,
|
|
40
|
+
NxisPropertyValueSpecification,
|
|
41
|
+
NxisValueOption,
|
|
42
|
+
NxisValueType,
|
|
43
|
+
ActionSubtype,
|
|
44
|
+
} from './contract';
|
|
45
|
+
export { isActionType, ACTION_SUBTYPES } from './contract';
|
|
46
|
+
|
|
47
|
+
// Re-export parse for advanced usage
|
|
48
|
+
export { readActions } from './parse';
|
|
49
|
+
|
|
50
|
+
// Re-export schema for testing / advanced usage
|
|
51
|
+
export { buildInputSchema } from './schema';
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form locator — heuristically locates the real `<form>` element
|
|
3
|
+
* corresponding to a NxisAction.
|
|
4
|
+
*
|
|
5
|
+
* Three-step cascade:
|
|
6
|
+
* 1. Target-based candidate collection (URL match or all forms for endpointless)
|
|
7
|
+
* 2. Field-set containment disambiguation (superset of valueName set)
|
|
8
|
+
* 3. Endpointless degenerate case (smallest superset wins)
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NxisAction } from './contract';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Locate the form element on the live page that corresponds to the given Action.
|
|
17
|
+
*
|
|
18
|
+
* @param action - The parsed NxisAction to locate a form for.
|
|
19
|
+
* @returns The matching HTMLFormElement, or null if no unambiguous match found.
|
|
20
|
+
*/
|
|
21
|
+
export function locateForm(action: NxisAction): HTMLFormElement | null {
|
|
22
|
+
const allForms = Array.from(document.querySelectorAll('form'));
|
|
23
|
+
if (allForms.length === 0) return null;
|
|
24
|
+
|
|
25
|
+
const actionValueNames = getActionValueNames(action);
|
|
26
|
+
const isEndpointless = !action.target.urlTemplate;
|
|
27
|
+
|
|
28
|
+
// Step 1: Target-based candidate collection
|
|
29
|
+
let candidates: HTMLFormElement[];
|
|
30
|
+
|
|
31
|
+
if (isEndpointless) {
|
|
32
|
+
// SelfPage / ClientSide — all forms are candidates (URL is uninformative)
|
|
33
|
+
candidates = allForms;
|
|
34
|
+
} else {
|
|
35
|
+
// URL target — filter by resolved action URL + method
|
|
36
|
+
candidates = filterByUrlAndMethod(
|
|
37
|
+
allForms,
|
|
38
|
+
action.target.urlTemplate!,
|
|
39
|
+
action.target.httpMethod
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (candidates.length === 0) {
|
|
44
|
+
// Fallback: try all forms if URL match yielded nothing
|
|
45
|
+
candidates = allForms;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (candidates.length === 1) {
|
|
49
|
+
return candidates[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Step 2: Field-set containment disambiguation
|
|
53
|
+
// Keep forms whose control names are a superset of the Action's valueNames
|
|
54
|
+
const fieldMatches = candidates
|
|
55
|
+
.map(form => ({
|
|
56
|
+
form,
|
|
57
|
+
controlNames: getFormControlNames(form),
|
|
58
|
+
}))
|
|
59
|
+
.filter(({ controlNames }) =>
|
|
60
|
+
actionValueNames.every(name => controlNames.has(name))
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (fieldMatches.length === 0) {
|
|
64
|
+
// No field-set match — fall back to first URL/method candidate
|
|
65
|
+
console.warn(
|
|
66
|
+
`[nxis/webmcp] No field-set match for action "${action.name}". ` +
|
|
67
|
+
`Expected fields: [${actionValueNames.join(', ')}]. ` +
|
|
68
|
+
`Falling back to first candidate.`
|
|
69
|
+
);
|
|
70
|
+
return candidates[0];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (fieldMatches.length === 1) {
|
|
74
|
+
return fieldMatches[0].form;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Step 3: Multiple field-set matches — pick smallest superset (closest match)
|
|
78
|
+
fieldMatches.sort((a, b) => a.controlNames.size - b.controlNames.size);
|
|
79
|
+
|
|
80
|
+
// If the two smallest are the same size, it's truly ambiguous
|
|
81
|
+
if (fieldMatches[0].controlNames.size === fieldMatches[1].controlNames.size) {
|
|
82
|
+
console.warn(
|
|
83
|
+
`[nxis/webmcp] Ambiguous form match for action "${action.name}". ` +
|
|
84
|
+
`${fieldMatches.length} forms have identical field sets. Returning null.`
|
|
85
|
+
);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return fieldMatches[0].form;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract the set of valueName keys from an Action's input specs.
|
|
94
|
+
*/
|
|
95
|
+
function getActionValueNames(action: NxisAction): string[] {
|
|
96
|
+
if (!action.input) return [];
|
|
97
|
+
return action.input.map(pvs => pvs.valueName);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the set of control `name` attributes from a form.
|
|
102
|
+
*/
|
|
103
|
+
function getFormControlNames(form: HTMLFormElement): Set<string> {
|
|
104
|
+
const names = new Set<string>();
|
|
105
|
+
const elements = form.elements;
|
|
106
|
+
for (let i = 0; i < elements.length; i++) {
|
|
107
|
+
const el = elements[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
108
|
+
if (el.name) {
|
|
109
|
+
names.add(el.name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return names;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Filter forms by resolved action URL + method match.
|
|
117
|
+
*/
|
|
118
|
+
function filterByUrlAndMethod(
|
|
119
|
+
forms: HTMLFormElement[],
|
|
120
|
+
urlTemplate: string,
|
|
121
|
+
httpMethod: string
|
|
122
|
+
): HTMLFormElement[] {
|
|
123
|
+
const target = resolveTemplateUrl(urlTemplate);
|
|
124
|
+
|
|
125
|
+
return forms.filter(form => {
|
|
126
|
+
// Method match (case-insensitive)
|
|
127
|
+
const formMethod = (form.method || 'GET').toUpperCase();
|
|
128
|
+
if (formMethod !== httpMethod.toUpperCase()) return false;
|
|
129
|
+
|
|
130
|
+
// If the template can't be resolved to an absolute URL, don't exclude on
|
|
131
|
+
// URL — let field-set disambiguation decide.
|
|
132
|
+
if (!target) return true;
|
|
133
|
+
|
|
134
|
+
const formAction = resolveFormAction(form);
|
|
135
|
+
return urlsMatch(formAction, target);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve an Action's urlTemplate to an absolute URL, dropping RFC 6570
|
|
141
|
+
* template vars (e.g. `?q={query}`) first. Returns null if unparseable.
|
|
142
|
+
*/
|
|
143
|
+
function resolveTemplateUrl(urlTemplate: string): URL | null {
|
|
144
|
+
const stripped = urlTemplate.replace(/\{[^}]*\}/g, '');
|
|
145
|
+
try {
|
|
146
|
+
return new URL(stripped, window.location.href);
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Match a form's resolved action URL against the Action's target URL.
|
|
154
|
+
*
|
|
155
|
+
* Requires the same origin, then compares paths on segment boundaries (the
|
|
156
|
+
* target path may be a prefix once trailing template vars are stripped). The
|
|
157
|
+
* query string is ignored — it carries the template's input values, not identity.
|
|
158
|
+
*/
|
|
159
|
+
function urlsMatch(formActionHref: string, target: URL): boolean {
|
|
160
|
+
let formUrl: URL;
|
|
161
|
+
try {
|
|
162
|
+
formUrl = new URL(formActionHref, window.location.href);
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (formUrl.origin !== target.origin) return false;
|
|
168
|
+
|
|
169
|
+
const formPath = formUrl.pathname.replace(/\/+$/, '');
|
|
170
|
+
const targetPath = target.pathname.replace(/\/+$/, '');
|
|
171
|
+
|
|
172
|
+
// Empty target path (template was origin-only) → any path on this origin.
|
|
173
|
+
if (targetPath === '') return true;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
formPath === targetPath ||
|
|
177
|
+
formPath.startsWith(targetPath + '/') ||
|
|
178
|
+
targetPath.startsWith(formPath + '/')
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve a form's action attribute to an absolute URL.
|
|
184
|
+
* Forms with no action, empty action, or `#` resolve to the current page.
|
|
185
|
+
*/
|
|
186
|
+
function resolveFormAction(form: HTMLFormElement): string {
|
|
187
|
+
const raw = form.getAttribute('action');
|
|
188
|
+
if (!raw || raw === '#' || raw.startsWith('#') || raw === '') {
|
|
189
|
+
return window.location.href.split('#')[0].split('?')[0];
|
|
190
|
+
}
|
|
191
|
+
if (raw.startsWith('javascript:')) {
|
|
192
|
+
return window.location.href.split('#')[0].split('?')[0];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
return new URL(raw, window.location.href).href;
|
|
197
|
+
} catch {
|
|
198
|
+
return raw;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD parser — reads Action specs from the page's structured data.
|
|
3
|
+
*
|
|
4
|
+
* Selects all `<script type="application/ld+json">` blocks, parses them,
|
|
5
|
+
* flattens `@graph` containers, and collects Action nodes.
|
|
6
|
+
*
|
|
7
|
+
* Total parsing: every step fails soft — malformed blocks are skipped,
|
|
8
|
+
* never fatal. A page may have dozens of JSON-LD blocks; we only collect
|
|
9
|
+
* the ones that are valid Action nodes.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { NxisAction, NxisEntryPoint, NxisPropertyValueSpecification, isActionType } from './contract';
|
|
15
|
+
|
|
16
|
+
/** The NxisAI namespace IRI — used for expanded property lookups. */
|
|
17
|
+
const NXIS_NS = 'https://nxis.ai/ns#';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a property value from an object, trying the compact nxis: prefix
|
|
21
|
+
* first, then the fully expanded IRI. JSON-LD processors may store properties
|
|
22
|
+
* under either form depending on whether @context was applied.
|
|
23
|
+
*/
|
|
24
|
+
function resolveValue(obj: Record<string, unknown>, compact: string, expanded: string): unknown {
|
|
25
|
+
return obj[compact] ?? obj[expanded];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve an array property, trying compact then expanded key.
|
|
30
|
+
*/
|
|
31
|
+
function resolveArray(obj: Record<string, unknown>, compact: string, expanded: string): unknown[] {
|
|
32
|
+
const val = resolveValue(obj, compact, expanded);
|
|
33
|
+
return Array.isArray(val) ? val : [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read all Action specs from JSON-LD blocks in the document.
|
|
38
|
+
*
|
|
39
|
+
* @param doc - The document to scan. Defaults to `document`.
|
|
40
|
+
* @returns Array of parsed NxisAction objects. Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function readActions(
|
|
43
|
+
doc: Document | undefined = typeof document !== 'undefined' ? document : undefined
|
|
44
|
+
): NxisAction[] {
|
|
45
|
+
const actions: NxisAction[] = [];
|
|
46
|
+
|
|
47
|
+
// Browser-only — no DOM means no JSON-LD to read.
|
|
48
|
+
if (!doc) return actions;
|
|
49
|
+
|
|
50
|
+
const scripts = doc.querySelectorAll('script[type="application/ld+json"]');
|
|
51
|
+
for (const script of Array.from(scripts)) {
|
|
52
|
+
const text = script.textContent;
|
|
53
|
+
if (!text) continue;
|
|
54
|
+
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
// Malformed JSON — skip, not fatal
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Flatten @graph containers and collect Action nodes
|
|
64
|
+
const nodes = flattenGraph(parsed);
|
|
65
|
+
for (const node of nodes) {
|
|
66
|
+
const action = parseActionNode(node);
|
|
67
|
+
if (action) {
|
|
68
|
+
actions.push(action);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return actions;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Flatten a parsed JSON-LD value into individual nodes.
|
|
78
|
+
* Handles top-level arrays, `@graph` containers, and single objects.
|
|
79
|
+
*/
|
|
80
|
+
function flattenGraph(parsed: unknown): unknown[] {
|
|
81
|
+
if (Array.isArray(parsed)) {
|
|
82
|
+
// Could be an array of nodes or an array containing @graph objects
|
|
83
|
+
const results: unknown[] = [];
|
|
84
|
+
for (const item of parsed) {
|
|
85
|
+
results.push(...flattenGraph(item));
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (parsed && typeof parsed === 'object') {
|
|
91
|
+
const obj = parsed as Record<string, unknown>;
|
|
92
|
+
// Check for @graph container
|
|
93
|
+
if (Array.isArray(obj['@graph'])) {
|
|
94
|
+
const results: unknown[] = [];
|
|
95
|
+
for (const item of obj['@graph']) {
|
|
96
|
+
results.push(...flattenGraph(item));
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
// Single object node
|
|
101
|
+
return [obj];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Attempt to parse a raw JSON node as a NxisAction.
|
|
109
|
+
* Returns null if the node is not a valid Action.
|
|
110
|
+
*/
|
|
111
|
+
function parseActionNode(node: unknown): NxisAction | null {
|
|
112
|
+
if (!node || typeof node !== 'object') return null;
|
|
113
|
+
|
|
114
|
+
const obj = node as Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
// Check @type — must end with "Action" or be a known subtype
|
|
117
|
+
const type = typeof obj['@type'] === 'string' ? obj['@type'] : null;
|
|
118
|
+
if (!type) return null;
|
|
119
|
+
if (!type.endsWith('Action') && !isActionType(type)) return null;
|
|
120
|
+
|
|
121
|
+
// Required fields
|
|
122
|
+
const id = typeof obj['@id'] === 'string' ? obj['@id'] : null;
|
|
123
|
+
const name = typeof obj['name'] === 'string' ? obj['name'] : null;
|
|
124
|
+
const target = parseEntryPoint(obj['target']);
|
|
125
|
+
|
|
126
|
+
if (!id || !name || !target) return null;
|
|
127
|
+
|
|
128
|
+
// Parse input PVS array — check both compact (nxis:input) and expanded IRI forms.
|
|
129
|
+
// The backend emits "nxis:input" (not bare "input") because schema.org has no
|
|
130
|
+
// input property on Action — using bare "input" would expand to the invalid
|
|
131
|
+
// https://schema.org/input under @context of https://schema.org/.
|
|
132
|
+
const rawInput = resolveArray(obj, 'nxis:input', NXIS_NS + 'input');
|
|
133
|
+
const input = rawInput
|
|
134
|
+
.map(parsePVS)
|
|
135
|
+
.filter((pvs): pvs is NxisPropertyValueSpecification => pvs !== null);
|
|
136
|
+
|
|
137
|
+
const description = typeof obj['description'] === 'string'
|
|
138
|
+
? obj['description']
|
|
139
|
+
: generateFallbackDescription(type, name, input);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
'@context': obj['@context'],
|
|
143
|
+
'@type': type,
|
|
144
|
+
'@id': id,
|
|
145
|
+
name,
|
|
146
|
+
description,
|
|
147
|
+
target,
|
|
148
|
+
input: input.length > 0 ? input : undefined,
|
|
149
|
+
'nxis:extractedAt': (
|
|
150
|
+
resolveValue(obj, 'nxis:extractedAt', NXIS_NS + 'extractedAt') as string | undefined
|
|
151
|
+
),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse a raw object as a NxisEntryPoint.
|
|
157
|
+
* Returns null if required fields are missing.
|
|
158
|
+
*/
|
|
159
|
+
function parseEntryPoint(raw: unknown): NxisEntryPoint | null {
|
|
160
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
161
|
+
|
|
162
|
+
const obj = raw as Record<string, unknown>;
|
|
163
|
+
|
|
164
|
+
const httpMethod = typeof obj['httpMethod'] === 'string'
|
|
165
|
+
? obj['httpMethod'].toUpperCase()
|
|
166
|
+
: null;
|
|
167
|
+
if (!httpMethod) return null;
|
|
168
|
+
|
|
169
|
+
const entryPoint: NxisEntryPoint = {
|
|
170
|
+
'@type': 'EntryPoint',
|
|
171
|
+
httpMethod,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Optional fields
|
|
175
|
+
if (typeof obj['encodingType'] === 'string') {
|
|
176
|
+
entryPoint.encodingType = obj['encodingType'];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// URL target
|
|
180
|
+
if (typeof obj['urlTemplate'] === 'string') {
|
|
181
|
+
entryPoint.urlTemplate = obj['urlTemplate'];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Endpointless target — try both compact and expanded forms
|
|
185
|
+
const targetKind = resolveValue(obj, 'nxis:targetKind', NXIS_NS + 'targetKind');
|
|
186
|
+
if (targetKind === 'selfPage' || targetKind === 'clientSide') {
|
|
187
|
+
entryPoint['nxis:targetKind'] = targetKind;
|
|
188
|
+
}
|
|
189
|
+
const pageUrl = resolveValue(obj, 'nxis:pageUrl', NXIS_NS + 'pageUrl');
|
|
190
|
+
if (typeof pageUrl === 'string') {
|
|
191
|
+
entryPoint['nxis:pageUrl'] = pageUrl;
|
|
192
|
+
}
|
|
193
|
+
const jsHandled = resolveValue(obj, 'nxis:jsHandled', NXIS_NS + 'jsHandled');
|
|
194
|
+
if (jsHandled === true) {
|
|
195
|
+
entryPoint['nxis:jsHandled'] = true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return entryPoint;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse a raw object as a NxisPropertyValueSpecification.
|
|
203
|
+
* Returns null if `valueName` is missing (no property key = no tool parameter).
|
|
204
|
+
*/
|
|
205
|
+
function parsePVS(raw: unknown): NxisPropertyValueSpecification | null {
|
|
206
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
207
|
+
|
|
208
|
+
const obj = raw as Record<string, unknown>;
|
|
209
|
+
|
|
210
|
+
const valueName = typeof obj['valueName'] === 'string' ? obj['valueName'] : null;
|
|
211
|
+
if (!valueName) return null;
|
|
212
|
+
|
|
213
|
+
const valueType = resolveValue(obj, 'nxis:valueType', NXIS_NS + 'valueType');
|
|
214
|
+
const vtStr = typeof valueType === 'string'
|
|
215
|
+
? valueType as NxisPropertyValueSpecification['nxis:valueType']
|
|
216
|
+
: 'string'; // default to string if missing
|
|
217
|
+
|
|
218
|
+
const pvs: NxisPropertyValueSpecification = {
|
|
219
|
+
'@type': 'PropertyValueSpecification',
|
|
220
|
+
valueName,
|
|
221
|
+
'nxis:valueType': vtStr,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Optional fields — only set if present and correct type
|
|
225
|
+
if (typeof obj['description'] === 'string') pvs.description = obj['description'];
|
|
226
|
+
if (obj['valueRequired'] === true) pvs.valueRequired = true;
|
|
227
|
+
if (typeof obj['defaultValue'] === 'string') pvs.defaultValue = obj['defaultValue'];
|
|
228
|
+
if (typeof obj['valueMinLength'] === 'number') pvs.valueMinLength = obj['valueMinLength'];
|
|
229
|
+
if (typeof obj['valueMaxLength'] === 'number') pvs.valueMaxLength = obj['valueMaxLength'];
|
|
230
|
+
if (typeof obj['minValue'] === 'number') pvs.minValue = obj['minValue'];
|
|
231
|
+
if (typeof obj['maxValue'] === 'number') pvs.maxValue = obj['maxValue'];
|
|
232
|
+
if (typeof obj['stepValue'] === 'number') pvs.stepValue = obj['stepValue'];
|
|
233
|
+
if (typeof obj['valuePattern'] === 'string') pvs.valuePattern = obj['valuePattern'];
|
|
234
|
+
if (obj['multipleValues'] === true) pvs.multipleValues = true;
|
|
235
|
+
if (obj['readonlyValue'] === true) pvs.readonlyValue = true;
|
|
236
|
+
|
|
237
|
+
// Parse options array — try both compact and expanded forms
|
|
238
|
+
const rawOptions = resolveArray(obj, 'nxis:valueOption', NXIS_NS + 'valueOption');
|
|
239
|
+
if (rawOptions.length > 0) {
|
|
240
|
+
const options = rawOptions
|
|
241
|
+
.filter((o: unknown) => o && typeof o === 'object' && typeof (o as Record<string, unknown>)['value'] === 'string')
|
|
242
|
+
.map((o: unknown) => {
|
|
243
|
+
const opt = o as Record<string, unknown>;
|
|
244
|
+
return {
|
|
245
|
+
'@type': 'nxis:ValueOption' as const,
|
|
246
|
+
value: opt['value'] as string,
|
|
247
|
+
name: typeof opt['name'] === 'string' ? opt['name'] : undefined,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
if (options.length > 0) {
|
|
251
|
+
pvs['nxis:valueOption'] = options;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return pvs;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Generate a human-readable fallback description from the action metadata.
|
|
260
|
+
* Chrome best practices: "Describe what the tool does and when to use it."
|
|
261
|
+
*/
|
|
262
|
+
function generateFallbackDescription(
|
|
263
|
+
type: string,
|
|
264
|
+
name: string,
|
|
265
|
+
input: NxisPropertyValueSpecification[]
|
|
266
|
+
): string {
|
|
267
|
+
// "CommunicateAction" → "communicate"
|
|
268
|
+
const verb = type.replace(/Action$/, '').toLowerCase();
|
|
269
|
+
const fieldList = input.map(f => f.valueName).join(', ');
|
|
270
|
+
|
|
271
|
+
if (input.length === 0) {
|
|
272
|
+
return `Trigger the ${verb} action "${name}".`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return `Fill the ${verb} form "${name}" with fields: ${fieldList}.`;
|
|
276
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM reconciliation — cross-checks the Action's claimed descriptors
|
|
3
|
+
* against the resolved form's real controls.
|
|
4
|
+
*
|
|
5
|
+
* The live DOM is authoritative. Where the JSON-LD and the form's
|
|
6
|
+
* real controls conflict, we trust the DOM.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { NxisAction, NxisPropertyValueSpecification } from './contract';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Public types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Fill strategy for a single reconciled field. */
|
|
18
|
+
export type FillStrategy =
|
|
19
|
+
| { kind: 'value'; element: HTMLInputElement | HTMLTextAreaElement }
|
|
20
|
+
| { kind: 'checked'; element: HTMLInputElement }
|
|
21
|
+
| { kind: 'radio'; name: string; elements: HTMLInputElement[] }
|
|
22
|
+
| { kind: 'checkboxGroup'; name: string; elements: HTMLInputElement[] }
|
|
23
|
+
| { kind: 'select'; element: HTMLSelectElement; allowedValues: string[] }
|
|
24
|
+
| { kind: 'multiSelect'; element: HTMLSelectElement; allowedValues: string[] }
|
|
25
|
+
| { kind: 'skip'; reason: string };
|
|
26
|
+
|
|
27
|
+
/** A reconciled field — pairs a valueName to its real element + fill strategy. */
|
|
28
|
+
export interface ReconciledField {
|
|
29
|
+
valueName: string;
|
|
30
|
+
pvs: NxisPropertyValueSpecification;
|
|
31
|
+
strategy: FillStrategy;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Public API
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reconcile the Action's claimed field descriptors against the form's real controls.
|
|
40
|
+
*
|
|
41
|
+
* Rules (from implementation plan):
|
|
42
|
+
* - valueName with no matching control → skip (don't fail the whole fill)
|
|
43
|
+
* - Real control type contradicts claimed valueType → follow the DOM
|
|
44
|
+
* - Real control is <select> → constrain to its actual <option>s
|
|
45
|
+
* - readonlyValue mismatch → trust DOM readonly attribute
|
|
46
|
+
*
|
|
47
|
+
* @param action - The parsed Action with claimed descriptors.
|
|
48
|
+
* @param form - The located form element.
|
|
49
|
+
* @returns Array of ReconciledField objects.
|
|
50
|
+
*/
|
|
51
|
+
export function reconcileFields(
|
|
52
|
+
action: NxisAction,
|
|
53
|
+
form: HTMLFormElement
|
|
54
|
+
): ReconciledField[] {
|
|
55
|
+
const specs = action.input ?? [];
|
|
56
|
+
return specs.map(pvs => reconcileField(pvs, form));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Internal
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function reconcileField(
|
|
64
|
+
pvs: NxisPropertyValueSpecification,
|
|
65
|
+
form: HTMLFormElement
|
|
66
|
+
): ReconciledField {
|
|
67
|
+
const make = (strategy: FillStrategy): ReconciledField => ({
|
|
68
|
+
valueName: pvs.valueName,
|
|
69
|
+
pvs,
|
|
70
|
+
strategy,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Find every control sharing this name. Radio groups and checkbox groups
|
|
74
|
+
// legitimately have multiple controls under one name — taking only the
|
|
75
|
+
// first would mishandle them.
|
|
76
|
+
const named = getNamedControls(form, pvs.valueName);
|
|
77
|
+
const control = named[0] ?? null;
|
|
78
|
+
|
|
79
|
+
if (!control) {
|
|
80
|
+
return make({ kind: 'skip', reason: `No control found with name="${pvs.valueName}"` });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Radio group — select the option whose value matches; collect the whole
|
|
84
|
+
// group so the executor can pick the right member. Disabled radios drop out.
|
|
85
|
+
if (control instanceof HTMLInputElement && control.type === 'radio') {
|
|
86
|
+
const elements = named.filter(
|
|
87
|
+
(el): el is HTMLInputElement =>
|
|
88
|
+
el instanceof HTMLInputElement && el.type === 'radio' && !el.disabled
|
|
89
|
+
);
|
|
90
|
+
if (elements.length === 0) {
|
|
91
|
+
return make({ kind: 'skip', reason: `Radio group "${pvs.valueName}" has no enabled options` });
|
|
92
|
+
}
|
|
93
|
+
return make({ kind: 'radio', name: pvs.valueName, elements });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Checkbox — a lone checkbox is a boolean toggle; several sharing a name
|
|
97
|
+
// form a multi-select group where the value(s) pick which boxes to check.
|
|
98
|
+
if (control instanceof HTMLInputElement && control.type === 'checkbox') {
|
|
99
|
+
const elements = named.filter(
|
|
100
|
+
(el): el is HTMLInputElement =>
|
|
101
|
+
el instanceof HTMLInputElement && el.type === 'checkbox' && !el.disabled
|
|
102
|
+
);
|
|
103
|
+
if (elements.length === 0) {
|
|
104
|
+
return make({ kind: 'skip', reason: `Checkbox "${pvs.valueName}" is disabled` });
|
|
105
|
+
}
|
|
106
|
+
if (elements.length > 1) {
|
|
107
|
+
return make({ kind: 'checkboxGroup', name: pvs.valueName, elements });
|
|
108
|
+
}
|
|
109
|
+
return make({ kind: 'checked', element: elements[0] });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if the real control is readonly (trust DOM over claimed)
|
|
113
|
+
const isReadonly = (control as HTMLInputElement).readOnly === true
|
|
114
|
+
|| control.getAttribute('readonly') !== null;
|
|
115
|
+
|
|
116
|
+
if (isReadonly) {
|
|
117
|
+
return make({ kind: 'skip', reason: `Control "${pvs.valueName}" is readonly in the DOM` });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Disabled controls cannot be filled
|
|
121
|
+
if ((control as HTMLInputElement).disabled) {
|
|
122
|
+
return make({ kind: 'skip', reason: `Control "${pvs.valueName}" is disabled` });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Select element — constrain to actual <option> values
|
|
126
|
+
if (control instanceof HTMLSelectElement) {
|
|
127
|
+
const allowedValues: string[] = [];
|
|
128
|
+
for (let i = 0; i < control.options.length; i++) {
|
|
129
|
+
const opt = control.options[i];
|
|
130
|
+
if (opt.value) {
|
|
131
|
+
allowedValues.push(opt.value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// <select multiple> accepts an array of values; a plain select takes one.
|
|
135
|
+
return make(
|
|
136
|
+
control.multiple
|
|
137
|
+
? { kind: 'multiSelect', element: control, allowedValues }
|
|
138
|
+
: { kind: 'select', element: control, allowedValues }
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Textarea
|
|
143
|
+
if (control instanceof HTMLTextAreaElement) {
|
|
144
|
+
return make({ kind: 'value', element: control });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Standard input — use value strategy
|
|
148
|
+
if (control instanceof HTMLInputElement) {
|
|
149
|
+
return make({ kind: 'value', element: control });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Unknown element type
|
|
153
|
+
return make({ kind: 'skip', reason: `Unsupported control type for "${pvs.valueName}"` });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Collect every form control that shares the given `name`. Form controls of
|
|
158
|
+
* the same name (radio/checkbox groups) all participate in the same field.
|
|
159
|
+
*/
|
|
160
|
+
function getNamedControls(form: HTMLFormElement, name: string): Element[] {
|
|
161
|
+
const out: Element[] = [];
|
|
162
|
+
const elements = form.elements;
|
|
163
|
+
for (let i = 0; i < elements.length; i++) {
|
|
164
|
+
const el = elements[i] as HTMLInputElement;
|
|
165
|
+
if (el.name === name) {
|
|
166
|
+
out.push(el);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|