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.
Files changed (114) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/dist/cli/README.md +119 -0
  3. package/dist/cli/appConfig.js +50 -0
  4. package/dist/cli/buildErrors.js +170 -0
  5. package/dist/cli/buildWarnings.js +28 -0
  6. package/dist/cli/devOutput.js +524 -0
  7. package/dist/cli/diagnostic.js +388 -0
  8. package/dist/cli/hints.js +69 -0
  9. package/dist/cli/package.json +3 -0
  10. package/dist/cli/spinner.js +141 -0
  11. package/dist/cli/theme.js +155 -0
  12. package/dist/errors/index.js +7 -3
  13. package/dist/errors/registry.js +8 -0
  14. package/dist/errors-index.json +7 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.jsx +1 -1
  17. package/dist/logger.js +61 -6
  18. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  23. package/dist/native/androidSetup.js +46 -57
  24. package/dist/native/buildAndroid/assets.js +22 -13
  25. package/dist/native/buildAndroid/build.js +98 -26
  26. package/dist/native/buildAndroid/config.js +43 -12
  27. package/dist/native/buildAndroid/index.js +49 -191
  28. package/dist/native/buildAppAndroid.js +19 -8
  29. package/dist/native/buildAppIos.js +35 -16
  30. package/dist/native/buildIos/assets.js +1 -6
  31. package/dist/native/buildIos/build.js +61 -94
  32. package/dist/native/buildIos/config.js +26 -8
  33. package/dist/native/buildIos/index.js +143 -64
  34. package/dist/native/buildIos/plugins.js +12 -15
  35. package/dist/native/internalPluginUtils.js +0 -1
  36. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  39. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  40. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  42. package/dist/native/pluginComposerAndroid.js +2 -3
  43. package/dist/native/renameAndroidProject.js +42 -20
  44. package/dist/native/setupEmulatorIos.js +28 -49
  45. package/dist/native/setupServer.js +12 -15
  46. package/dist/native/terminalProgress.js +186 -147
  47. package/dist/native/utils.js +335 -54
  48. package/dist/otel.js +15 -9
  49. package/dist/scripts/build.js +314 -42
  50. package/dist/scripts/generateOfflineManifest.js +11 -1
  51. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  52. package/dist/scripts/preServerInit.js +32 -10
  53. package/dist/scripts/preflight.js +115 -11
  54. package/dist/scripts/scriptUtils.js +34 -0
  55. package/dist/scripts/serve.js +65 -9
  56. package/dist/scripts/start.js +58 -21
  57. package/dist/server/expressServer.js +167 -32
  58. package/dist/server/renderer/document/Body.d.ts +0 -11
  59. package/dist/server/renderer/document/Body.jsx +0 -10
  60. package/dist/server/renderer/document/Head.d.ts +0 -13
  61. package/dist/server/renderer/document/Head.jsx +0 -12
  62. package/dist/server/renderer/handler.jsx +87 -11
  63. package/dist/server/utils/validator.js +14 -11
  64. package/dist/vite/manifest-categorization-plugin.js +7 -5
  65. package/dist/vite/resolveDevServerConfig.js +9 -2
  66. package/dist/vite/vite.config.client.js +19 -1
  67. package/dist/vite/vite.config.js +96 -19
  68. package/dist/vite/vite.config.server.js +21 -0
  69. package/dist/web-router/components/MetaTag.jsx +20 -14
  70. package/dist/web-router/components/MetaTag.test.jsx +108 -32
  71. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  72. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  73. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  74. package/dist/web-router/context.d.ts +15 -1
  75. package/dist/web-router/context.jsx +11 -0
  76. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  77. package/dist/webmcp/declarative.js +207 -0
  78. package/dist/webmcp/declarative.test.js +122 -0
  79. package/dist/webmcp/errors.js +59 -0
  80. package/dist/webmcp/errors.test.js +47 -0
  81. package/dist/webmcp/index.js +40 -0
  82. package/dist/webmcp/native.js +37 -0
  83. package/dist/webmcp/native.test.js +41 -0
  84. package/dist/webmcp/pageInfo.js +119 -0
  85. package/dist/webmcp/pageInfo.test.js +85 -0
  86. package/dist/webmcp/registry.dom.test.js +175 -0
  87. package/dist/webmcp/registry.js +540 -0
  88. package/dist/webmcp/registry.test.js +228 -0
  89. package/dist/webmcp/routeContext.js +11 -0
  90. package/dist/webmcp/shim-entry.js +8 -0
  91. package/dist/webmcp/shim.js +149 -0
  92. package/dist/webmcp/useTool.js +124 -0
  93. package/package.json +4 -7
  94. package/test/cli/buildErrors.test.ts +173 -0
  95. package/test/cli/buildWarnings.test.cjs +62 -0
  96. package/test/cli/devOutput.test.ts +549 -0
  97. package/test/cli/diagnostic.test.ts +434 -0
  98. package/test/cli/output.test.cjs +90 -0
  99. package/test/cli/spinner.test.ts +177 -0
  100. package/test/cli/theme.test.ts +163 -0
  101. package/test/native/redactSecrets.test.ts +72 -0
  102. package/test/native/utils.test.ts +532 -0
  103. package/test/scripts/build.test.ts +275 -0
  104. package/test/scripts/preflight.test.ts +100 -2
  105. package/test/scripts/serve.test.ts +102 -0
  106. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  107. package/test/server/handler.test.ts +17 -0
  108. package/test/server/logger.test.ts +185 -0
  109. package/test/server/manifestCache.test.ts +13 -0
  110. package/test/server/otel.test.ts +556 -0
  111. package/test/server/validator.test.ts +19 -8
  112. package/vitest.config.ts +4 -0
  113. package/dist/scripts/checkVersion.js +0 -3
  114. 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 { deleteHeadTagsByDataAttribute, getMetaData } from "../utils/metaDataUtils.jsx";
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
- const [metaTags, setMetaTags] = useState([<meta key={0}></meta>]);
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
- if (Array.isArray(mergedMetaTags) && mergedMetaTags.length > 0) {
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
- if (Array.isArray(metaTags) && metaTags.length >= 0) {
27
- return <HelmetProvider>
28
- <Helmet>{metaTags}</Helmet>
29
- </HelmetProvider>;
30
- }
31
- return <></>;
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 { describe, expect, it, vi, afterEach } from "vitest"
3
- import { render, waitFor, act } from "@testing-library/react"
4
- import { MemoryRouter, Routes, Route, useNavigate } from "react-router"
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("keeps the placeholder meta tag when no route provides setMetaData", () => {
51
+ it("renders no metadata when no route provides setMetaData", () => {
57
52
  renderMetaTag({ matchedRoutes: [{ route: { component: {} } }] })
58
- // Component should still render (Helmet/HelmetProvider tree),
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("re-runs setMetaData and clears previous tags when the location changes", async () => {
64
- // A rerender() with a NEW <MemoryRouter initialEntries> does not
65
- // navigate an already-mounted router -- initialEntries is only
66
- // read on first mount. Confirmed directly: that approach left
67
- // setMetaData called once even after "changing" the path.
68
- // Navigating within one mounted router (via a real useNavigate
69
- // call, triggered here through a button) is what actually
70
- // changes useLocation() and re-fires MetaTag's effect.
71
- const setMetaData = vi.fn(() => [<meta key="d" name="description" content="v1" />])
72
- const matchedRoutes = [{ route: { component: { setMetaData } } }]
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("/b")}>
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={["/a"]}>
85
- <OneMgRouterContext.Provider value={{ matchedRoutes }}>
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
- <NavButton />
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
- await act(async () => {
96
- document.querySelector("button").click()
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
+ }