catalyst-core 1.0.1 → 1.0.2-canary.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/bin/catalyst.js +105 -1
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +50 -0
- package/dist/cli/buildErrors.js +170 -0
- package/dist/cli/buildWarnings.js +28 -0
- package/dist/cli/devOutput.js +524 -0
- package/dist/cli/diagnostic.js +388 -0
- package/dist/cli/hints.js +69 -0
- package/dist/cli/package.json +3 -0
- package/dist/cli/spinner.js +141 -0
- package/dist/cli/theme.js +155 -0
- package/dist/errors/index.js +7 -3
- package/dist/errors/registry.js +8 -0
- package/dist/errors-index.json +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.jsx +1 -1
- package/dist/logger.js +61 -6
- package/dist/native/androidProject/app/build.gradle.kts +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
- package/dist/native/androidSetup.js +46 -57
- package/dist/native/buildAndroid/assets.js +22 -13
- package/dist/native/buildAndroid/build.js +98 -26
- package/dist/native/buildAndroid/config.js +43 -12
- package/dist/native/buildAndroid/index.js +49 -191
- package/dist/native/buildAppAndroid.js +19 -8
- package/dist/native/buildAppIos.js +35 -16
- package/dist/native/buildIos/assets.js +1 -6
- package/dist/native/buildIos/build.js +61 -94
- package/dist/native/buildIos/config.js +26 -8
- package/dist/native/buildIos/index.js +143 -64
- package/dist/native/buildIos/plugins.js +12 -15
- package/dist/native/internalPluginUtils.js +0 -1
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
- package/dist/native/pluginComposerAndroid.js +2 -3
- package/dist/native/renameAndroidProject.js +42 -20
- package/dist/native/setupEmulatorIos.js +28 -49
- package/dist/native/setupServer.js +12 -15
- package/dist/native/terminalProgress.js +186 -147
- package/dist/native/utils.js +335 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +314 -42
- package/dist/scripts/generateOfflineManifest.js +11 -1
- package/dist/scripts/loadEnvironmentVariables.js +24 -2
- package/dist/scripts/preServerInit.js +32 -10
- package/dist/scripts/preflight.js +115 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +65 -9
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- package/dist/server/renderer/document/Body.d.ts +0 -11
- package/dist/server/renderer/document/Body.jsx +0 -10
- package/dist/server/renderer/document/Head.d.ts +0 -13
- package/dist/server/renderer/document/Head.jsx +0 -12
- package/dist/server/renderer/handler.jsx +87 -11
- package/dist/server/utils/validator.js +14 -11
- package/dist/vite/manifest-categorization-plugin.js +7 -5
- package/dist/vite/resolveDevServerConfig.js +9 -2
- package/dist/vite/vite.config.client.js +19 -1
- package/dist/vite/vite.config.js +96 -19
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +20 -14
- package/dist/web-router/components/MetaTag.test.jsx +108 -32
- package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
- package/dist/web-router/components/RouterDataProvider.jsx +35 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
- package/dist/web-router/context.d.ts +15 -1
- package/dist/web-router/context.jsx +11 -0
- package/dist/webmcp/WebMcpProvider.jsx +234 -0
- package/dist/webmcp/declarative.js +207 -0
- package/dist/webmcp/declarative.test.js +122 -0
- package/dist/webmcp/errors.js +59 -0
- package/dist/webmcp/errors.test.js +47 -0
- package/dist/webmcp/index.js +40 -0
- package/dist/webmcp/native.js +37 -0
- package/dist/webmcp/native.test.js +41 -0
- package/dist/webmcp/pageInfo.js +119 -0
- package/dist/webmcp/pageInfo.test.js +85 -0
- package/dist/webmcp/registry.dom.test.js +175 -0
- package/dist/webmcp/registry.js +540 -0
- package/dist/webmcp/registry.test.js +228 -0
- package/dist/webmcp/routeContext.js +11 -0
- package/dist/webmcp/shim-entry.js +8 -0
- package/dist/webmcp/shim.js +149 -0
- package/dist/webmcp/useTool.js +124 -0
- package/package.json +4 -7
- package/test/cli/buildErrors.test.ts +173 -0
- package/test/cli/buildWarnings.test.cjs +62 -0
- package/test/cli/devOutput.test.ts +549 -0
- package/test/cli/diagnostic.test.ts +434 -0
- package/test/cli/output.test.cjs +90 -0
- package/test/cli/spinner.test.ts +177 -0
- package/test/cli/theme.test.ts +163 -0
- package/test/native/redactSecrets.test.ts +72 -0
- package/test/native/utils.test.ts +532 -0
- package/test/scripts/build.test.ts +275 -0
- package/test/scripts/preflight.test.ts +100 -2
- package/test/scripts/serve.test.ts +102 -0
- package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
- package/test/server/handler.test.ts +17 -0
- package/test/server/logger.test.ts +185 -0
- package/test/server/manifestCache.test.ts +13 -0
- package/test/server/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- package/vitest.config.ts +4 -0
- package/dist/scripts/checkVersion.js +0 -3
- package/dist/scripts/cleanCache.js +0 -30
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import React, { useContext, useState, useEffect } from "react";
|
|
2
|
-
import { HelmetProvider, Helmet } from "react-helmet-async";
|
|
1
|
+
import React, { useContext, useState, useEffect, useRef } from "react";
|
|
3
2
|
import { OneMgRouterContext } from "../context.jsx";
|
|
4
3
|
import { useRouterData } from "./RouterDataProvider.jsx";
|
|
5
|
-
import {
|
|
4
|
+
import { getMetaData } from "../utils/metaDataUtils.jsx";
|
|
6
5
|
import { useLocation } from "react-router";
|
|
7
6
|
|
|
8
7
|
/**
|
|
@@ -14,19 +13,26 @@ export const MetaTag = () => {
|
|
|
14
13
|
} = useContext(OneMgRouterContext);
|
|
15
14
|
const routeData = useRouterData();
|
|
16
15
|
const location = useLocation();
|
|
17
|
-
|
|
16
|
+
// null until the first client update, so server and hydration renders match (both empty).
|
|
17
|
+
const [metaTags, setMetaTags] = useState(null);
|
|
18
|
+
// The server renders these tags into <head> (Head.tsx). React 19 does not
|
|
19
|
+
// adopt them when MetaTag renders its own copies, and renders non-metadata
|
|
20
|
+
// tags such as <script type="application/ld+json"> in place, so the server
|
|
21
|
+
// copies would outlive the page they describe. Captured on mount, before
|
|
22
|
+
// any client copy exists, and removed once the client copies are in.
|
|
23
|
+
const serverTags = useRef(null);
|
|
18
24
|
useEffect(() => {
|
|
25
|
+
serverTags.current ??= Array.from(document.head.querySelectorAll("[data-catalyst]"));
|
|
19
26
|
const mergedMetaTags = getMetaData(matchedRoutes, routeData);
|
|
20
|
-
|
|
21
|
-
setMetaTags(mergedMetaTags);
|
|
22
|
-
}
|
|
23
|
-
return () => deleteHeadTagsByDataAttribute("catalyst");
|
|
27
|
+
setMetaTags(Array.isArray(mergedMetaTags) ? mergedMetaTags : []);
|
|
24
28
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
29
|
}, [location]);
|
|
26
|
-
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!metaTags || !serverTags.current?.length) return;
|
|
32
|
+
serverTags.current.forEach(el => el.remove());
|
|
33
|
+
serverTags.current = [];
|
|
34
|
+
}, [metaTags]);
|
|
35
|
+
|
|
36
|
+
// React 19 hoists document metadata rendered by components into <head>.
|
|
37
|
+
return <>{metaTags}</>;
|
|
32
38
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from "react"
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { renderToString } from "react-dom/server"
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import { render, waitFor, act, cleanup } from "@testing-library/react"
|
|
5
|
+
import { MemoryRouter, Routes, Route, useLocation, useNavigate } from "react-router"
|
|
5
6
|
import { MetaTag } from "./MetaTag.jsx"
|
|
6
7
|
import { OneMgRouterContext } from "../context.jsx"
|
|
7
8
|
import { RouterContext } from "./RouterDataProvider.jsx"
|
|
@@ -26,10 +27,6 @@ function renderMetaTag({ matchedRoutes = [], routerData = {} } = {}) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
describe("MetaTag", () => {
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
document.head.innerHTML = ""
|
|
31
|
-
})
|
|
32
|
-
|
|
33
30
|
it("renders without crashing when there are no matched routes", () => {
|
|
34
31
|
expect(() => renderMetaTag()).not.toThrow()
|
|
35
32
|
})
|
|
@@ -48,53 +45,132 @@ describe("MetaTag", () => {
|
|
|
48
45
|
await waitFor(() => {
|
|
49
46
|
expect(document.head.querySelector('meta[name="description"]')).not.toBeNull()
|
|
50
47
|
})
|
|
51
|
-
expect(document.head.querySelector('meta[name="description"]').getAttribute("content")).toBe(
|
|
52
|
-
"hello"
|
|
53
|
-
)
|
|
48
|
+
expect(document.head.querySelector('meta[name="description"]').getAttribute("content")).toBe("hello")
|
|
54
49
|
})
|
|
55
50
|
|
|
56
|
-
it("
|
|
51
|
+
it("renders no metadata when no route provides setMetaData", () => {
|
|
57
52
|
renderMetaTag({ matchedRoutes: [{ route: { component: {} } }] })
|
|
58
|
-
|
|
59
|
-
// even with the single empty placeholder <meta> from useState.
|
|
60
|
-
expect(document.querySelector("head")).not.toBeNull()
|
|
53
|
+
expect(document.head.querySelector("[data-catalyst]")).toBeNull()
|
|
61
54
|
})
|
|
62
55
|
|
|
63
|
-
it("
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
56
|
+
it("clears previous metadata when navigating to a route without setMetaData", async () => {
|
|
57
|
+
const routeWithMetadata = [
|
|
58
|
+
{
|
|
59
|
+
route: {
|
|
60
|
+
component: {
|
|
61
|
+
setMetaData: () => [<meta key="d" name="description" content="with metadata" />],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
function RouteMetadata() {
|
|
68
|
+
const location = useLocation()
|
|
69
|
+
const matchedRoutes = location.pathname === "/with-metadata" ? routeWithMetadata : []
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<OneMgRouterContext.Provider value={{ matchedRoutes }}>
|
|
73
|
+
<RouterContext.Provider value={{}}>
|
|
74
|
+
<MetaTag />
|
|
75
|
+
</RouterContext.Provider>
|
|
76
|
+
</OneMgRouterContext.Provider>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
73
79
|
|
|
74
80
|
function NavButton() {
|
|
75
81
|
const navigate = useNavigate()
|
|
76
82
|
return (
|
|
77
|
-
<button type="button" onClick={() => navigate("/
|
|
83
|
+
<button type="button" onClick={() => navigate("/without-metadata")}>
|
|
78
84
|
go
|
|
79
85
|
</button>
|
|
80
86
|
)
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
render(
|
|
84
|
-
<MemoryRouter initialEntries={["/
|
|
85
|
-
<
|
|
90
|
+
<MemoryRouter initialEntries={["/with-metadata"]}>
|
|
91
|
+
<NavButton />
|
|
92
|
+
<RouteMetadata />
|
|
93
|
+
</MemoryRouter>
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
expect(document.head.querySelector('meta[name="description"]')).not.toBeNull()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
await act(async () => {
|
|
101
|
+
document.querySelector("button").click()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(document.head.querySelector('meta[name="description"]')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe("server-rendered head tags", () => {
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
cleanup()
|
|
112
|
+
document.head.innerHTML = ""
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const pageMeta = (page) => [
|
|
116
|
+
<title key="t">{`Page ${page}`}</title>,
|
|
117
|
+
<meta key="d" name="description" content={`about ${page}`} />,
|
|
118
|
+
<script key="ld" type="application/ld+json">{`{"page":"${page}"}`}</script>,
|
|
119
|
+
]
|
|
120
|
+
const routesFor = (pathname) => [
|
|
121
|
+
{ route: { component: { setMetaData: () => pageMeta(pathname === "/a" ? "a" : "b") } } },
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
function App() {
|
|
125
|
+
const location = useLocation()
|
|
126
|
+
const navigate = useNavigate()
|
|
127
|
+
return (
|
|
128
|
+
<OneMgRouterContext.Provider value={{ matchedRoutes: routesFor(location.pathname) }}>
|
|
86
129
|
<RouterContext.Provider value={{}}>
|
|
87
|
-
<
|
|
130
|
+
<button type="button" onClick={() => navigate("/b")}>
|
|
131
|
+
go
|
|
132
|
+
</button>
|
|
88
133
|
<MetaTag />
|
|
89
134
|
</RouterContext.Provider>
|
|
90
135
|
</OneMgRouterContext.Provider>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
const tree = (
|
|
139
|
+
<MemoryRouter initialEntries={["/a"]}>
|
|
140
|
+
<App />
|
|
91
141
|
</MemoryRouter>
|
|
92
142
|
)
|
|
93
|
-
await waitFor(() => expect(setMetaData).toHaveBeenCalledTimes(1))
|
|
94
143
|
|
|
95
|
-
|
|
96
|
-
|
|
144
|
+
it("hydrates without a mismatch and replaces the server's JSON-LD, title and meta on navigation", async () => {
|
|
145
|
+
// What Head.tsx streams for /a: getMetaData output stamped data-catalyst.
|
|
146
|
+
document.head.innerHTML = renderToString(<>{pageMeta("a")}</>).replace(
|
|
147
|
+
/<(title|meta|script)/g,
|
|
148
|
+
'<$1 data-catalyst="true"'
|
|
149
|
+
)
|
|
150
|
+
const container = document.createElement("div")
|
|
151
|
+
container.innerHTML = renderToString(tree)
|
|
152
|
+
document.body.appendChild(container)
|
|
153
|
+
const onRecoverableError = vi.fn()
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
render(tree, { container, hydrate: true, onRecoverableError })
|
|
157
|
+
})
|
|
158
|
+
expect(onRecoverableError).not.toHaveBeenCalled()
|
|
159
|
+
|
|
160
|
+
await act(async () => {
|
|
161
|
+
container.querySelector("button").click()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(document.title).toBe("Page b")
|
|
166
|
+
})
|
|
167
|
+
const jsonLd = document.querySelectorAll('script[type="application/ld+json"]')
|
|
168
|
+
expect(Array.from(jsonLd, (el) => el.textContent)).toEqual(['{"page":"b"}'])
|
|
169
|
+
expect(document.head.querySelector('script[type="application/ld+json"]')).toBeNull()
|
|
170
|
+
expect(document.querySelectorAll("title")).toHaveLength(1)
|
|
171
|
+
expect(
|
|
172
|
+
Array.from(document.querySelectorAll('meta[name="description"]'), (el) => el.content)
|
|
173
|
+
).toEqual(["about b"])
|
|
97
174
|
})
|
|
98
|
-
await waitFor(() => expect(setMetaData).toHaveBeenCalledTimes(2))
|
|
99
175
|
})
|
|
100
176
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import type { Location, NavigateFunction, Params, RouteObject } from "react-router";
|
|
2
|
+
import type { Location, NavigateFunction, Params, RouteMatch, RouteObject } from "react-router";
|
|
3
3
|
/**
|
|
4
4
|
* Data a route fetcher produced, as exposed by useCurrentRouteData / useRouterData.
|
|
5
5
|
*/
|
|
@@ -94,3 +94,27 @@ export declare const useCurrentRouteData: () => RouteData;
|
|
|
94
94
|
* @throws If used outside RouterDataProvider Context
|
|
95
95
|
*/
|
|
96
96
|
export declare const useRouterData: () => RoutesData;
|
|
97
|
+
/**
|
|
98
|
+
* Router state (location, params, navigate, the matched-route chain) read
|
|
99
|
+
* through OneMgRouterContext rather than react-router's own hooks directly.
|
|
100
|
+
*
|
|
101
|
+
* Exists so a `catalyst-core/*` SUBPATH package (e.g. `catalyst-core/webmcp`)
|
|
102
|
+
* can read router state without importing react-router itself. A subpath is
|
|
103
|
+
* a separate module resolution root from the app's own react-router import
|
|
104
|
+
* under Vite's dev SSR (react-router is deliberately excluded from
|
|
105
|
+
* vite.config.js's `dedupe`), so two `useLocation()` calls made from two
|
|
106
|
+
* different entry points can land on two different react-router module
|
|
107
|
+
* instances with two different, mutually invisible RouterContexts — the
|
|
108
|
+
* hook throws "may be used only in the context of a <Router> component"
|
|
109
|
+
* even though a <Router> genuinely is an ancestor. Going through this
|
|
110
|
+
* context instead relies only on `react`'s context identity, and `react`
|
|
111
|
+
* (unlike react-router) IS deduped.
|
|
112
|
+
*
|
|
113
|
+
* @throws If used outside RouterDataProvider Context
|
|
114
|
+
*/
|
|
115
|
+
export declare const useRouterState: () => {
|
|
116
|
+
location: Location;
|
|
117
|
+
params: Params;
|
|
118
|
+
navigate: NavigateFunction;
|
|
119
|
+
matchedRoutes: RouteMatch<string, RouteObject>[];
|
|
120
|
+
};
|
|
@@ -270,7 +270,10 @@ export const RouterDataProvider = ({
|
|
|
270
270
|
return <OneMgRouterContext.Provider value={{
|
|
271
271
|
matchedRoutes,
|
|
272
272
|
refetchData,
|
|
273
|
-
clear
|
|
273
|
+
clear,
|
|
274
|
+
location,
|
|
275
|
+
params,
|
|
276
|
+
navigate
|
|
274
277
|
}}>
|
|
275
278
|
<RouterContext.Provider value={{
|
|
276
279
|
...routeData,
|
|
@@ -331,3 +334,34 @@ export const useRouterData = () => {
|
|
|
331
334
|
}
|
|
332
335
|
return context;
|
|
333
336
|
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Router state (location, params, navigate, the matched-route chain) read
|
|
340
|
+
* through OneMgRouterContext rather than react-router's own hooks directly.
|
|
341
|
+
*
|
|
342
|
+
* Exists so a `catalyst-core/*` SUBPATH package (e.g. `catalyst-core/webmcp`)
|
|
343
|
+
* can read router state without importing react-router itself. A subpath is
|
|
344
|
+
* a separate module resolution root from the app's own react-router import
|
|
345
|
+
* under Vite's dev SSR (react-router is deliberately excluded from
|
|
346
|
+
* vite.config.js's `dedupe`), so two `useLocation()` calls made from two
|
|
347
|
+
* different entry points can land on two different react-router module
|
|
348
|
+
* instances with two different, mutually invisible RouterContexts — the
|
|
349
|
+
* hook throws "may be used only in the context of a <Router> component"
|
|
350
|
+
* even though a <Router> genuinely is an ancestor. Going through this
|
|
351
|
+
* context instead relies only on `react`'s context identity, and `react`
|
|
352
|
+
* (unlike react-router) IS deduped.
|
|
353
|
+
*
|
|
354
|
+
* @throws If used outside RouterDataProvider Context
|
|
355
|
+
*/
|
|
356
|
+
export const useRouterState = () => {
|
|
357
|
+
const context = useContext(OneMgRouterContext);
|
|
358
|
+
if (context.location === undefined) {
|
|
359
|
+
throw new Error("useRouterState must be used within a RouterDataProvider");
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
location: context.location,
|
|
363
|
+
params: context.params || {},
|
|
364
|
+
navigate: context.navigate,
|
|
365
|
+
matchedRoutes: context.matchedRoutes || []
|
|
366
|
+
};
|
|
367
|
+
};
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
RouterDataProvider,
|
|
7
7
|
useRouterData,
|
|
8
8
|
useCurrentRouteData,
|
|
9
|
+
useRouterState,
|
|
9
10
|
serverDataFetcher,
|
|
10
11
|
} from "./RouterDataProvider.jsx"
|
|
11
12
|
|
|
@@ -308,3 +309,46 @@ describe("useCurrentRouteData", () => {
|
|
|
308
309
|
await waitFor(() => expect(screen.getByTestId("ready")).toBeInTheDocument())
|
|
309
310
|
})
|
|
310
311
|
})
|
|
312
|
+
|
|
313
|
+
describe("useRouterState", () => {
|
|
314
|
+
function StateProbe() {
|
|
315
|
+
const { location, params, navigate, matchedRoutes } = useRouterState()
|
|
316
|
+
return (
|
|
317
|
+
<div
|
|
318
|
+
data-testid="state"
|
|
319
|
+
data-path={location.pathname}
|
|
320
|
+
data-params={JSON.stringify(params)}
|
|
321
|
+
data-nav={typeof navigate}
|
|
322
|
+
data-matched={Array.isArray(matchedRoutes) ? "array" : "other"}
|
|
323
|
+
/>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
it("exposes the router location, params, navigate and matched routes under a RouterDataProvider", async () => {
|
|
328
|
+
render(
|
|
329
|
+
<MemoryRouter initialEntries={["/items/42"]}>
|
|
330
|
+
<Routes>
|
|
331
|
+
<Route
|
|
332
|
+
path="/items/:id"
|
|
333
|
+
element={
|
|
334
|
+
<RouterDataProvider initialState={{}} config={{}}>
|
|
335
|
+
<StateProbe />
|
|
336
|
+
</RouterDataProvider>
|
|
337
|
+
}
|
|
338
|
+
/>
|
|
339
|
+
</Routes>
|
|
340
|
+
</MemoryRouter>
|
|
341
|
+
)
|
|
342
|
+
const el = await screen.findByTestId("state")
|
|
343
|
+
expect(el.dataset.path).toBe("/items/42")
|
|
344
|
+
expect(JSON.parse(el.dataset.params)).toEqual({ id: "42" })
|
|
345
|
+
expect(el.dataset.nav).toBe("function")
|
|
346
|
+
expect(el.dataset.matched).toBe("array")
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("throws a clear error when used outside a RouterDataProvider", () => {
|
|
350
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
351
|
+
expect(() => render(<StateProbe />)).toThrow(/useRouterState must be used within a RouterDataProvider/)
|
|
352
|
+
spy.mockRestore()
|
|
353
|
+
})
|
|
354
|
+
})
|
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import type { RouteMatch, RouteObject } from "react-router";
|
|
1
|
+
import type { Location, NavigateFunction, Params, RouteMatch, RouteObject } from "react-router";
|
|
2
2
|
/**
|
|
3
3
|
* Internal context shared among the router components. `matchedRoutes` is the
|
|
4
4
|
* match chain for the current location; the two callbacks are supplied by
|
|
5
5
|
* RouterDataProvider and consumed by useCurrentRouteData.
|
|
6
|
+
*
|
|
7
|
+
* `location`/`params`/`navigate` are the same values RouterDataProvider
|
|
8
|
+
* already reads via react-router's own hooks (useLocation/useParams/
|
|
9
|
+
* useNavigate) — republished here so a `catalyst-core/*` SUBPATH package
|
|
10
|
+
* (loaded through a separate module resolution than the app's own
|
|
11
|
+
* react-router import under Vite's dev SSR — `react-router` is deliberately
|
|
12
|
+
* excluded from vite.config.js's `dedupe`, see that file's comment) can read
|
|
13
|
+
* router state through ONE shared React context (identity guaranteed by
|
|
14
|
+
* `react`, which IS deduped) instead of calling react-router's hooks itself
|
|
15
|
+
* and risking a second, unlinked `react-router` module instance whose
|
|
16
|
+
* internal context a subpath's own hook calls can't see.
|
|
6
17
|
*/
|
|
7
18
|
export interface OneMgRouterContextValue {
|
|
8
19
|
matchedRoutes?: RouteMatch<string, RouteObject>[];
|
|
9
20
|
refetchData?: (route: RouteObject, routeKey: string) => (args?: Record<string, any>) => void | Promise<void>;
|
|
10
21
|
clear?: (routeKey: string) => (wait?: number) => void;
|
|
22
|
+
location?: Location;
|
|
23
|
+
params?: Params;
|
|
24
|
+
navigate?: NavigateFunction;
|
|
11
25
|
}
|
|
12
26
|
/**
|
|
13
27
|
* @description This is internal context made for sharing data among router components
|
|
@@ -4,6 +4,17 @@ import { createContext } from "react";
|
|
|
4
4
|
* Internal context shared among the router components. `matchedRoutes` is the
|
|
5
5
|
* match chain for the current location; the two callbacks are supplied by
|
|
6
6
|
* RouterDataProvider and consumed by useCurrentRouteData.
|
|
7
|
+
*
|
|
8
|
+
* `location`/`params`/`navigate` are the same values RouterDataProvider
|
|
9
|
+
* already reads via react-router's own hooks (useLocation/useParams/
|
|
10
|
+
* useNavigate) — republished here so a `catalyst-core/*` SUBPATH package
|
|
11
|
+
* (loaded through a separate module resolution than the app's own
|
|
12
|
+
* react-router import under Vite's dev SSR — `react-router` is deliberately
|
|
13
|
+
* excluded from vite.config.js's `dedupe`, see that file's comment) can read
|
|
14
|
+
* router state through ONE shared React context (identity guaranteed by
|
|
15
|
+
* `react`, which IS deduped) instead of calling react-router's hooks itself
|
|
16
|
+
* and risking a second, unlinked `react-router` module instance whose
|
|
17
|
+
* internal context a subpath's own hook calls can't see.
|
|
7
18
|
*/
|
|
8
19
|
|
|
9
20
|
/**
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from "react"
|
|
2
|
+
// Relative import, not the "catalyst-core" package specifier: under Vite's
|
|
3
|
+
// dev SSR, re-entering through the package specifier from INSIDE
|
|
4
|
+
// node_modules/catalyst-core/dist/webmcp/ can resolve to a separate module
|
|
5
|
+
// record than the app's own `import ... from "catalyst-core"` — even though
|
|
6
|
+
// both point at the identical file on disk — which defeats the whole point
|
|
7
|
+
// of routing state through a shared React context (see the file-level
|
|
8
|
+
// comment below). A relative path from this file straight to the main
|
|
9
|
+
// entry's compiled output guarantees the SAME module record both places.
|
|
10
|
+
import { useRouterState } from "../index.jsx"
|
|
11
|
+
import { RouteContext } from "./routeContext.js"
|
|
12
|
+
import { hasBackend, reapUnmatched, registerTool, unregisterTool, inspect, newOwnerKey } from "./registry.js"
|
|
13
|
+
import { declarativeToolFor, frameworkTools } from "./declarative.js"
|
|
14
|
+
import { pageInfoTool } from "./pageInfo.js"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* WebMcpProvider — framework glue for WebMCP.
|
|
18
|
+
*
|
|
19
|
+
* Mounted once, inside RouterDataProvider (so it sees the same route match
|
|
20
|
+
* chain) and wrapping the app. Two jobs:
|
|
21
|
+
*
|
|
22
|
+
* 1. Publishes the current innermost route path pattern via <RouteContext>
|
|
23
|
+
* so every useTool() beneath it tags its registration with a route.
|
|
24
|
+
*
|
|
25
|
+
* 2. On every navigation, hands the registry the set of still-matched route
|
|
26
|
+
* patterns. Any route-scoped tool whose route is no longer matched is
|
|
27
|
+
* aborted + unregistered *now* — leaving a route aborts its tools.
|
|
28
|
+
*
|
|
29
|
+
* Route state (location/params/navigate/the matched-route chain) comes from
|
|
30
|
+
* catalyst-core's `useRouterState()`, NOT from react-router's own hooks
|
|
31
|
+
* called directly in this file. That is deliberate: this module ships as
|
|
32
|
+
* the `catalyst-core/webmcp` SUBPATH, a separate module-resolution root from
|
|
33
|
+
* the app's own react-router import under Vite's dev SSR (react-router is
|
|
34
|
+
* deliberately excluded from vite.config.js's `dedupe`). Calling
|
|
35
|
+
* `useLocation()` etc. here risks landing on a second, unlinked
|
|
36
|
+
* react-router module instance whose RouterContext this component can't
|
|
37
|
+
* see — `useRouterState()` sidesteps that by reading through a plain React
|
|
38
|
+
* context RouterDataProvider already populates, and `react` context
|
|
39
|
+
* identity (unlike react-router's) IS guaranteed single-instance.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} props
|
|
42
|
+
* @param {React.ReactNode} props.children
|
|
43
|
+
* @param {any[]} props.routes the app's OWN route table (whatever shape it
|
|
44
|
+
* builds `preparedRoutes()`/`getRoutes()` from), in its natural nested
|
|
45
|
+
* form — WebMcpProvider flattens it itself. Required: unlike the earlier
|
|
46
|
+
* POC, core cannot reach into an app's own routing module, so the caller
|
|
47
|
+
* passes its routes explicitly.
|
|
48
|
+
* @param {(flatRoute: any) => boolean} [props.filterNavigable] optional
|
|
49
|
+
* predicate deciding which flattened, non-parameterised routes are offered
|
|
50
|
+
* through the `navigate` framework tool's enum. Default: every such route.
|
|
51
|
+
* An app with many generated pages (e.g. one route per doc page) should
|
|
52
|
+
* exclude those here — they're reachable through their own search/open
|
|
53
|
+
* tools instead of bloating `navigate`'s enum to the whole site map.
|
|
54
|
+
*/
|
|
55
|
+
export function WebMcpProvider({ children, routes, filterNavigable }) {
|
|
56
|
+
const { location, params, navigate, matchedRoutes: matches } = useRouterState()
|
|
57
|
+
|
|
58
|
+
const currentRoutePath = useMemo(() => {
|
|
59
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
60
|
+
const patt = patternOf(matches[i])
|
|
61
|
+
if (patt) return patt
|
|
62
|
+
}
|
|
63
|
+
return ""
|
|
64
|
+
}, [matches])
|
|
65
|
+
|
|
66
|
+
const matchedPatterns = useMemo(() => {
|
|
67
|
+
const set = new Set()
|
|
68
|
+
for (const m of matches) {
|
|
69
|
+
const patt = patternOf(m)
|
|
70
|
+
if (patt) set.add(patt)
|
|
71
|
+
}
|
|
72
|
+
return set
|
|
73
|
+
}, [matches])
|
|
74
|
+
|
|
75
|
+
// A stable string key so effects only fire when the matched SET changes.
|
|
76
|
+
const patternsKey = useMemo(() => Array.from(matchedPatterns).sort().join("|"), [matchedPatterns])
|
|
77
|
+
|
|
78
|
+
const firstRun = useRef(true)
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (typeof document === "undefined") return
|
|
82
|
+
if (hasBackend()) return
|
|
83
|
+
// Neither a native document.modelContext nor an app-installed shim
|
|
84
|
+
// is present — every registerTool() call below still "succeeds"
|
|
85
|
+
// against the in-memory fallback (registry.js's safety net), but no
|
|
86
|
+
// agent can see any of it. This is silent otherwise, and the
|
|
87
|
+
// resulting "nothing is broken, nothing works" state is exactly the
|
|
88
|
+
// kind of thing worth a loud one-time warning for.
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.warn(
|
|
91
|
+
"[webmcp] No document.modelContext found (native or shim) — WebMCP tools are registering into an " +
|
|
92
|
+
"in-memory fallback that no agent can see. Install a shim (e.g. catalyst-core/webmcp/shim's " +
|
|
93
|
+
"installShim()) before this provider mounts if you need WebMCP to work in a browser without " +
|
|
94
|
+
"native support."
|
|
95
|
+
)
|
|
96
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
97
|
+
}, [])
|
|
98
|
+
|
|
99
|
+
// Keep live route facts + the matched-route chain in a ref so the framework
|
|
100
|
+
// tools (registered once, below) always report the *current* page without
|
|
101
|
+
// re-registering.
|
|
102
|
+
const routeFactsRef = useRef({ pathname: location.pathname, pattern: currentRoutePath, params, matches })
|
|
103
|
+
routeFactsRef.current = { pathname: location.pathname, pattern: currentRoutePath, params, matches }
|
|
104
|
+
|
|
105
|
+
const flatRoutes = useMemo(() => flattenRoutes(routes), [routes])
|
|
106
|
+
|
|
107
|
+
// Register the DECLARATIVE read tools + the framework tools ONCE, as
|
|
108
|
+
// app-lifetime tools (routePath ""), so an agent can navigate from anywhere
|
|
109
|
+
// to anywhere. Imperative useTool() action tools stay route-scoped.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (typeof document === "undefined") return undefined
|
|
112
|
+
const owners = []
|
|
113
|
+
|
|
114
|
+
const getCurrent = () => ({
|
|
115
|
+
pathname: routeFactsRef.current.pathname,
|
|
116
|
+
pattern: routeFactsRef.current.pattern,
|
|
117
|
+
params: routeFactsRef.current.params,
|
|
118
|
+
tools: inspect().tools.map((t) => ({ name: t.name, routePath: t.routePath })),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Declarative, one per route that declares route.tool.
|
|
122
|
+
for (const route of flatRoutes) {
|
|
123
|
+
const spec = declarativeToolFor(route, navigate)
|
|
124
|
+
if (!spec) continue
|
|
125
|
+
const key = newOwnerKey()
|
|
126
|
+
const res = registerTool(key, "", spec)
|
|
127
|
+
if (res.ok) owners.push(key)
|
|
128
|
+
else
|
|
129
|
+
// eslint-disable-next-line no-console
|
|
130
|
+
console.warn(`[webmcp] declarative tool "${spec.name}" failed to register:`, res.error)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Framework: navigate + get_current_route + get_page_info.
|
|
134
|
+
const navigableRoutes = filterNavigable ? flatRoutes.filter(filterNavigable) : flatRoutes
|
|
135
|
+
const fw = [
|
|
136
|
+
...frameworkTools(navigableRoutes, navigate, getCurrent, flatRoutes),
|
|
137
|
+
pageInfoTool(
|
|
138
|
+
() => routeFactsRef.current.matches,
|
|
139
|
+
() => ({})
|
|
140
|
+
),
|
|
141
|
+
]
|
|
142
|
+
for (const spec of fw) {
|
|
143
|
+
const key = newOwnerKey()
|
|
144
|
+
const res = registerTool(key, "", spec)
|
|
145
|
+
if (res.ok) owners.push(key)
|
|
146
|
+
else
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.warn(
|
|
149
|
+
`[webmcp] framework tool "${spec.name}" failed to register — a route tool may already use that name:`,
|
|
150
|
+
res.error
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return () => owners.forEach(unregisterTool)
|
|
155
|
+
// navigate identity is stable for the life of the router; flatRoutes
|
|
156
|
+
// is memoised on the routes prop, which an app should pass a stable
|
|
157
|
+
// reference for (the same module-level array every render).
|
|
158
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
159
|
+
}, [flatRoutes])
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (firstRun.current) {
|
|
163
|
+
firstRun.current = false
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const reaped = reapUnmatched(matchedPatterns)
|
|
167
|
+
if (reaped.length) {
|
|
168
|
+
// eslint-disable-next-line no-console
|
|
169
|
+
console.info(
|
|
170
|
+
`[webmcp] navigated to ${location.pathname} — reaped ${reaped.length} stale tool(s): ${reaped.join(", ")}`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
}, [patternsKey, location.pathname])
|
|
175
|
+
|
|
176
|
+
return <RouteContext.Provider value={currentRoutePath}>{children}</RouteContext.Provider>
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Flatten a nested route table into the flat array declarative.js's
|
|
181
|
+
* declarativeToolFor()/frameworkTools() expect: one entry per NAVIGABLE
|
|
182
|
+
* route, each carrying an absolute `.path` and (if present) its own
|
|
183
|
+
* `.tool` config.
|
|
184
|
+
*
|
|
185
|
+
* Rules:
|
|
186
|
+
* - A route with `children` is a layout wrapper — recurse into its
|
|
187
|
+
* children, joining relative child paths against the parent's absolute
|
|
188
|
+
* path. Its own entry is dropped UNLESS it itself has a `path` (a layout
|
|
189
|
+
* can also be directly navigable, e.g. nested `/parent` + `/parent/:id`).
|
|
190
|
+
* - A route with `index: true` and no `path` is the default child of its
|
|
191
|
+
* parent — it has no path of its own to register a tool under, so it is
|
|
192
|
+
* skipped. (Its parent's own path, if any, already covers "/".)
|
|
193
|
+
* - A route with neither `path` nor `children` nor `index` contributes
|
|
194
|
+
* nothing (matches ServerRouter's own tolerance for pathless wrapper
|
|
195
|
+
* routes, e.g. an outer <RouterDataProvider> element with no path).
|
|
196
|
+
*/
|
|
197
|
+
export function flattenRoutes(routes, parentPath = "") {
|
|
198
|
+
if (!Array.isArray(routes)) return []
|
|
199
|
+
const out = []
|
|
200
|
+
for (const route of routes) {
|
|
201
|
+
if (route.index || typeof route.path !== "string") {
|
|
202
|
+
if (Array.isArray(route.children)) {
|
|
203
|
+
out.push(...flattenRoutes(route.children, parentPath))
|
|
204
|
+
}
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
const absolutePath = route.path.startsWith("/")
|
|
208
|
+
? route.path
|
|
209
|
+
: `${parentPath.replace(/\/$/, "")}/${route.path}`
|
|
210
|
+
out.push({ ...route, path: absolutePath })
|
|
211
|
+
if (Array.isArray(route.children)) {
|
|
212
|
+
out.push(...flattenRoutes(route.children, absolutePath))
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A RouteMatch has `.route.path` (the pattern segment as react-router sees
|
|
220
|
+
* it — usually just the leaf segment, not the full absolute path) and
|
|
221
|
+
* `.pathname` (the full matched URL). We want the full pattern, so this
|
|
222
|
+
* joins every matched segment's `.route.path` up the chain rather than
|
|
223
|
+
* trusting either alone.
|
|
224
|
+
*
|
|
225
|
+
* A *pathless* layout route (no `.route.path`) contributes NO scope — it is
|
|
226
|
+
* not a navigable destination. Falling back to `.pathname` instead would pin
|
|
227
|
+
* whatever page happened to be current into the matched-set, and stop that
|
|
228
|
+
* route's tools from ever being reaped.
|
|
229
|
+
*/
|
|
230
|
+
function patternOf(match) {
|
|
231
|
+
if (!match || !match.route || typeof match.route.path !== "string" || !match.route.path) return ""
|
|
232
|
+
const p = match.route.path
|
|
233
|
+
return p.startsWith("/") ? p : `/${p}`
|
|
234
|
+
}
|