janux 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janux",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "The agent-native UI framework core. One component, two faces: a view for humans, typed MCP tools & resources for AI agents.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,3 +16,4 @@ export {
16
16
  type WebMCPHandle,
17
17
  type WebMCPToolDescriptor,
18
18
  } from './webmcp';
19
+ export { collectPageLinks, createNavigateTool, type PageLink } from './navigate-tool';
@@ -0,0 +1,55 @@
1
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
3
+ import { GLOW_CLASS } from './glow';
4
+ import { collectPageLinks, createNavigateTool } from './navigate-tool';
5
+
6
+ beforeAll(() => GlobalRegistrator.register({ url: 'https://app.test/docs/guide/components' }));
7
+ afterAll(() => GlobalRegistrator.unregister());
8
+
9
+ beforeEach(() => {
10
+ document.body.innerHTML = `
11
+ <nav>
12
+ <a href="/docs/guide/components">Components</a>
13
+ <a href="/docs/guide/cli-and-deployment">CLI and deployment</a>
14
+ <a href="/docs/guide/cli-and-deployment">CLI (duplicate)</a>
15
+ <a href="https://github.com/aralroca/Janux">GitHub</a>
16
+ </nav>`;
17
+ });
18
+
19
+ describe('collectPageLinks', () => {
20
+ it('collects same-origin links, deduped, keeping the first label', () => {
21
+ const links = collectPageLinks();
22
+
23
+ expect(links).toEqual([
24
+ { path: '/docs/guide/components', label: 'Components' },
25
+ { path: '/docs/guide/cli-and-deployment', label: 'CLI and deployment' },
26
+ ]);
27
+ });
28
+ });
29
+
30
+ describe('createNavigateTool', () => {
31
+ it('glows the pressed link, then navigates', async () => {
32
+ const assign = mock((path: string) => path);
33
+
34
+ (location as any).assign = assign;
35
+ const result = createNavigateTool().execute({ path: '/docs/guide/cli-and-deployment' });
36
+ const anchor = document.querySelector('a[href="/docs/guide/cli-and-deployment"]')!;
37
+
38
+ expect(result).toEqual({ navigated: '/docs/guide/cli-and-deployment', label: 'CLI and deployment' });
39
+ expect(anchor.classList.contains(GLOW_CLASS)).toBe(true);
40
+ expect(assign).not.toHaveBeenCalled();
41
+
42
+ await new Promise((resolve) => setTimeout(resolve, 420));
43
+ expect(assign).toHaveBeenCalledWith('/docs/guide/cli-and-deployment');
44
+ });
45
+
46
+ it('rejects paths not linked on the page and returns the real links', () => {
47
+ const assign = mock((path: string) => path);
48
+
49
+ (location as any).assign = assign;
50
+ const result = createNavigateTool().execute({ path: '/docs/made/up' }) as any;
51
+
52
+ expect(assign).not.toHaveBeenCalled();
53
+ expect(result.links.map((link: any) => link.path)).toContain('/docs/guide/components');
54
+ });
55
+ });
@@ -0,0 +1,78 @@
1
+ import { glowElement, injectGlowStyles } from './glow';
2
+ import type { WebMCPToolDescriptor } from './webmcp';
3
+
4
+ export interface PageLink {
5
+ path: string;
6
+ label: string;
7
+ }
8
+
9
+ /** Bounds the tool payload on link-heavy pages. */
10
+ const MAX_LINKS = 100;
11
+
12
+ function toPageLink(anchor: HTMLAnchorElement): PageLink | undefined {
13
+ const url = new URL(anchor.getAttribute('href') ?? '', location.href);
14
+
15
+ if (url.origin !== location.origin) return undefined;
16
+
17
+ return { path: url.pathname + url.search + url.hash, label: anchor.textContent?.trim() ?? '' };
18
+ }
19
+
20
+ /**
21
+ * The app's real navigation surface: every same-origin link currently in the
22
+ * DOM — which is exactly what the JSX projected, SSR'd or client-rendered,
23
+ * with zero authoring effort.
24
+ */
25
+ export function collectPageLinks(): PageLink[] {
26
+ const anchors = [...document.querySelectorAll<HTMLAnchorElement>('a[href]')];
27
+
28
+ return anchors
29
+ .map(toPageLink)
30
+ .filter((link): link is PageLink => link !== undefined)
31
+ .filter((link, index, all) => all.findIndex((other) => other.path === link.path) === index)
32
+ .slice(0, MAX_LINKS);
33
+ }
34
+
35
+ /** Long enough to see the glow on the link the agent "pressed", short enough to feel instant. */
36
+ const GLOW_BEFORE_NAV_MS = 350;
37
+
38
+ function anchorFor(path: string): HTMLAnchorElement | undefined {
39
+ const matches = [...document.querySelectorAll<HTMLAnchorElement>('a[href]')].filter(
40
+ (anchor) => toPageLink(anchor)?.path === path,
41
+ );
42
+
43
+ // Sidebars often repeat a link in a hidden mobile drawer — glow the visible one.
44
+ return matches.find((anchor) => anchor.getClientRects().length > 0) ?? matches[0];
45
+ }
46
+
47
+ function navigateTo(path: string): unknown {
48
+ const links = collectPageLinks();
49
+ const resolved = new URL(path, location.href);
50
+ const wanted = resolved.pathname + resolved.search + resolved.hash;
51
+ const target = links.find((link) => link.path === wanted);
52
+ const anchor = target ? anchorFor(target.path) : undefined;
53
+
54
+ // Models hallucinate paths; handing back the real links makes the retry self-correcting.
55
+ if (!target) return { error: `No link to "${path}" on this page. Current links:`, links };
56
+ if (anchor) {
57
+ injectGlowStyles();
58
+ glowElement(anchor);
59
+ anchor.scrollIntoView({ block: 'nearest' });
60
+ }
61
+ setTimeout(() => location.assign(target.path), anchor ? GLOW_BEFORE_NAV_MS : 0);
62
+
63
+ return { navigated: target.path, label: target.label };
64
+ }
65
+
66
+ /** The built-in `navigate` WebMCP tool `installWebMCP` registers on every page. */
67
+ export function createNavigateTool(): WebMCPToolDescriptor {
68
+ return {
69
+ name: 'navigate',
70
+ description: 'Navigate this app to one of the links on the current page, by path.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: { path: { type: 'string', description: 'Target path, e.g. /docs/guide/navigation' } },
74
+ required: ['path'],
75
+ },
76
+ execute: (input) => navigateTo(String((input as { path?: unknown })?.path ?? '')),
77
+ };
78
+ }
@@ -115,6 +115,27 @@ describe('WebMCP integration', () => {
115
115
  expect(context.listTools().map((tool) => tool.name)).toEqual(['next']);
116
116
  });
117
117
 
118
+ it('auto-registers the built-in navigate tool alongside manifest tools', async () => {
119
+ const client = await serveAndBoot();
120
+ const handle = installWebMCP(client);
121
+
122
+ await handle.sync();
123
+ expect(polyfillOf().listTools().map((tool) => tool.name)).toContain('navigate');
124
+ });
125
+
126
+ it('an app tool named navigate makes the built-in step aside', async () => {
127
+ manifestTools = [
128
+ { name: 'navigate', description: 'App-owned nav', guard: 'auto', input: { type: 'object', properties: { total: { type: 'number' } } } },
129
+ ];
130
+ const client = await serveAndBoot();
131
+ const handle = installWebMCP(client);
132
+
133
+ await handle.sync();
134
+ const navigate = polyfillOf().listTools().find((tool) => tool.name === 'navigate');
135
+
136
+ expect(navigate?.description).toContain('App-owned nav');
137
+ });
138
+
118
139
  it('registers route manifest tools plus live local tools, with sanitized names', async () => {
119
140
  const client = await serveAndBoot();
120
141
 
@@ -1,5 +1,6 @@
1
1
  import type { Manifest, ManifestTool } from '../manifest';
2
2
  import type { JanuxBridge } from './bridge';
3
+ import { createNavigateTool } from './navigate-tool';
3
4
 
4
5
  export interface WebMCPToolDescriptor {
5
6
  name: string;
@@ -133,12 +134,15 @@ export function installWebMCP(bridge: JanuxBridge): WebMCPHandle {
133
134
 
134
135
  const run = async (): Promise<void> => {
135
136
  const tools = mergedTools(bridge, await routeTools());
137
+ // An app tool named `navigate` owns the name; the built-in steps aside.
138
+ const taken = tools.some((tool) => tool.name.replace(/[^\w-]/g, '_') === 'navigate');
139
+ const descriptors = [...(taken ? [] : [createNavigateTool()]), ...tools.map((tool) => descriptorFor(tool, bridge))];
136
140
 
137
141
  controller?.abort();
138
142
  controller = new AbortController();
139
- for (const tool of tools) {
143
+ for (const descriptor of descriptors) {
140
144
  try {
141
- await context.registerTool(descriptorFor(tool, bridge), { signal: controller.signal });
145
+ await context.registerTool(descriptor, { signal: controller.signal });
142
146
  } catch {
143
147
  // One rejected registration (schema quirks, duplicate names…) must not drop the rest.
144
148
  }