From a4a7fe26b1e79d1085875f7791bdea7260008d6d Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 08:52:23 +0100 Subject: [PATCH 1/4] fix(embed): the Starlight, Astro and VitePress plugins need no React installed The plugins mounted the launcher from ondocs/embed, whose dialog chunk imports react, react-dom, @radix-ui/react-dialog and cmdk, so a Vue or Astro site had to install all four or its build failed. A new build, ondocs/embed/standalone, is ondocs/embed with production React, Radix and cmdk bundled into the dialog's chunk. launcherScript mounts with it, so the guides now install only ondocs, ai and @ai-sdk/openai. js-weight.mjs gets an on-open budget for each site, and the fresh Starlight and VitePress sites in the consumers job install no React. --- .changeset/standalone-dialog.md | 7 + README.md | 2 +- examples/docusaurus/docs/astro.md | 10 +- examples/docusaurus/docs/script-tag.md | 4 +- examples/docusaurus/docs/vitepress.md | 8 +- examples/nextjs/content/docs/astro.md | 10 +- examples/nextjs/content/docs/script-tag.md | 4 +- examples/nextjs/content/docs/vitepress.md | 8 +- .../src/content/docs/integrations/astro.md | 10 +- .../content/docs/integrations/script-tag.md | 4 +- .../content/docs/integrations/vitepress.md | 8 +- examples/vitepress/docs/astro.md | 10 +- examples/vitepress/docs/script-tag.md | 4 +- examples/vitepress/docs/vitepress.md | 8 +- package.json | 4 + scripts/consumer-site.mjs | 26 ++- scripts/js-weight.mjs | 23 ++- skills/ondocs-setup/SKILL.md | 4 +- src/astro/integration.ts | 7 +- src/integration/launcher.ts | 17 +- src/vitepress/index.ts | 4 +- test/astro.test.ts | 4 +- test/integration-entry.test.ts | 2 +- test/standalone-dialog.test.ts | 179 ++++++++++++++++++ test/vitepress.test.ts | 2 +- tsdown.config.ts | 26 +++ 26 files changed, 338 insertions(+), 57 deletions(-) create mode 100644 .changeset/standalone-dialog.md create mode 100644 test/standalone-dialog.test.ts diff --git a/.changeset/standalone-dialog.md b/.changeset/standalone-dialog.md new file mode 100644 index 00000000..9409c9bf --- /dev/null +++ b/.changeset/standalone-dialog.md @@ -0,0 +1,7 @@ +--- +'ondocs': patch +--- + +The Astro, Starlight and VitePress plugins no longer need React. Their dialog now comes prebuilt with its own React, Radix and cmdk, from the new `ondocs/embed/standalone`, so a site installs `ondocs`, `ai` and `@ai-sdk/openai` and nothing else. Before, a Vue or Astro site without React had to install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`, or its build failed. The dialog still loads only the first time it is wanted, in a chunk of its own: 92.6 KB gzipped on the Starlight example (93.6 KB before) and 95.6 KB on the VitePress one (96.8 KB before). A site that installed the four packages only for the dialog can remove them. On an Astro site with React islands, the dialog no longer shares their React. + +`ondocs/embed/standalone` has the same `mountAskDialog` and `openAskDialog` as `ondocs/embed`, for an app with a bundler and no React. `launcherScript` from `ondocs/integration` now mounts the launcher with it. diff --git a/README.md b/README.md index a9256686..d1574f6c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Each answers from an endpoint you deploy as one function next to the site, rate- **Live demos:** [Starlight](https://ondocs-starlight.vercel.app), [Docusaurus](https://ondocs-docusaurus.vercel.app), [VitePress](https://ondocs-vitepress.vercel.app). -**Starlight.** `npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk`, then: +**Starlight.** `npm i ondocs ai @ai-sdk/openai`, then: ```js // astro.config.mjs diff --git a/examples/docusaurus/docs/astro.md b/examples/docusaurus/docs/astro.md index 91d63af3..a8eb6b10 100644 --- a/examples/docusaurus/docs/astro.md +++ b/examples/docusaurus/docs/astro.md @@ -10,12 +10,14 @@ Live demo: [these docs as a Starlight site](https://ondocs-starlight.vercel.app) ## Install for Astro -The dialog is a React component, so install React and its two dependencies along with the AI SDK: +Install ondocs and the AI SDK. The dialog comes with its own React, so your site needs none: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` +With ondocs 0.11.0 or earlier, the dialog renders with the site's React: also install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`. + ## Add it to Starlight To add ondocs to Starlight, put the plugin in Starlight's `plugins` array in `astro.config.mjs`: @@ -55,7 +57,9 @@ The Astro integration reads each page's `
`. Choose another part with `cont ## The dialog in Astro -Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. Vite bundles the dialog with your pages, so it shares React with your own islands. With ``, the dialog stays across navigations and citations navigate through the router. +Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. With ``, the dialog stays across navigations and citations navigate through the router. + +The dialog comes prebuilt with its own React, Radix and cmdk, from `ondocs/embed/standalone`, also on a site with React islands of its own. Vite splits it off into a chunk that loads the first time it is wanted: 92.6 KB of JavaScript, gzipped, on the Starlight example. Before then, a page carries only the button and the shortcut, and the dialog's stylesheet, 3.5 KB gzipped, which the plugin adds to the site's CSS. Measured on an Apple M1 Max with `node scripts/js-weight.mjs starlight`, in headless Chrome, and `gzip -9` of `ondocs/react/styles.css`. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `launcher: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. diff --git a/examples/docusaurus/docs/script-tag.md b/examples/docusaurus/docs/script-tag.md index 283056e9..9772e2f7 100644 --- a/examples/docusaurus/docs/script-tag.md +++ b/examples/docusaurus/docs/script-tag.md @@ -84,4 +84,6 @@ From a script, `window.Ondocs.open(question?)` opens it. With `data-manual`, the ## With a bundler -In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed` does the same with your own copy of React. Install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. +In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed/standalone` does the same, with React, Radix and cmdk built into the dialog's chunk, which your bundler splits off to load on first use. Install nothing else, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. The Astro, Starlight and VitePress plugins mount it this way. + +`mountAskDialog(options)` from `ondocs/embed` is the same, rendered with your own copy of React instead, for an app that already loads React: install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk` with it. diff --git a/examples/docusaurus/docs/vitepress.md b/examples/docusaurus/docs/vitepress.md index c36c33a3..93a5fc49 100644 --- a/examples/docusaurus/docs/vitepress.md +++ b/examples/docusaurus/docs/vitepress.md @@ -10,12 +10,14 @@ Live demo: [these docs as a VitePress site](https://ondocs-vitepress.vercel.app) ## Install for VitePress -The dialog is a React component, so install React and its two dependencies along with the AI SDK. They load with the dialog, the first time a visitor opens it, and never on the rest of your Vue pages: +Install ondocs and the AI SDK. The dialog comes with its own React, which loads with it the first time a visitor opens it, and never on the rest of your Vue pages: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` +With ondocs 0.11.0 or earlier, the dialog renders with the site's React: also install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`. + ## Add it to .vitepress/config.mts To add ondocs to VitePress, wrap the config in `withOndocs`: @@ -47,7 +49,7 @@ The plugin reads each page's Markdown as the default theme renders it, the `.vp- ## The dialog in VitePress -The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 3 KB of JavaScript, and the dialog loads the first time it is wanted. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](./script-tag.md) to `head` instead. +The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 3 KB of JavaScript, and the dialog's stylesheet, 3.5 KB gzipped, which the plugin adds to the site's CSS. The dialog comes prebuilt with its own React, Radix and cmdk, from `ondocs/embed/standalone`, and loads the first time it is wanted: 95.6 KB of JavaScript, gzipped, on the VitePress example. Measured on an Apple M1 Max with `node scripts/js-weight.mjs vitepress`, in headless Chrome, and `gzip -9` of `ondocs/react/styles.css`. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](./script-tag.md) to `head` instead. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `launcher: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. diff --git a/examples/nextjs/content/docs/astro.md b/examples/nextjs/content/docs/astro.md index e331b260..8d685a2e 100644 --- a/examples/nextjs/content/docs/astro.md +++ b/examples/nextjs/content/docs/astro.md @@ -11,12 +11,14 @@ Live demo: [these docs as a Starlight site](https://ondocs-starlight.vercel.app) ## Install for Astro -The dialog is a React component, so install React and its two dependencies along with the AI SDK: +Install ondocs and the AI SDK. The dialog comes with its own React, so your site needs none: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` +With ondocs 0.11.0 or earlier, the dialog renders with the site's React: also install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`. + ## Add it to Starlight To add ondocs to Starlight, put the plugin in Starlight's `plugins` array in `astro.config.mjs`: @@ -56,7 +58,9 @@ The Astro integration reads each page's `
`. Choose another part with `cont ## The dialog in Astro -Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. Vite bundles the dialog with your pages, so it shares React with your own islands. With ``, the dialog stays across navigations and citations navigate through the router. +Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. With ``, the dialog stays across navigations and citations navigate through the router. + +The dialog comes prebuilt with its own React, Radix and cmdk, from `ondocs/embed/standalone`, also on a site with React islands of its own. Vite splits it off into a chunk that loads the first time it is wanted: 92.6 KB of JavaScript, gzipped, on the Starlight example. Before then, a page carries only the button and the shortcut, and the dialog's stylesheet, 3.5 KB gzipped, which the plugin adds to the site's CSS. Measured on an Apple M1 Max with `node scripts/js-weight.mjs starlight`, in headless Chrome, and `gzip -9` of `ondocs/react/styles.css`. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `launcher: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. diff --git a/examples/nextjs/content/docs/script-tag.md b/examples/nextjs/content/docs/script-tag.md index 97b789ee..09f2962d 100644 --- a/examples/nextjs/content/docs/script-tag.md +++ b/examples/nextjs/content/docs/script-tag.md @@ -85,4 +85,6 @@ From a script, `window.Ondocs.open(question?)` opens it. With `data-manual`, the ## With a bundler -In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed` does the same with your own copy of React. Install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. +In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed/standalone` does the same, with React, Radix and cmdk built into the dialog's chunk, which your bundler splits off to load on first use. Install nothing else, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. The Astro, Starlight and VitePress plugins mount it this way. + +`mountAskDialog(options)` from `ondocs/embed` is the same, rendered with your own copy of React instead, for an app that already loads React: install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk` with it. diff --git a/examples/nextjs/content/docs/vitepress.md b/examples/nextjs/content/docs/vitepress.md index e13c8b20..46c8726e 100644 --- a/examples/nextjs/content/docs/vitepress.md +++ b/examples/nextjs/content/docs/vitepress.md @@ -11,12 +11,14 @@ Live demo: [these docs as a VitePress site](https://ondocs-vitepress.vercel.app) ## Install for VitePress -The dialog is a React component, so install React and its two dependencies along with the AI SDK. They load with the dialog, the first time a visitor opens it, and never on the rest of your Vue pages: +Install ondocs and the AI SDK. The dialog comes with its own React, which loads with it the first time a visitor opens it, and never on the rest of your Vue pages: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` +With ondocs 0.11.0 or earlier, the dialog renders with the site's React: also install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`. + ## Add it to .vitepress/config.mts To add ondocs to VitePress, wrap the config in `withOndocs`: @@ -48,7 +50,7 @@ The plugin reads each page's Markdown as the default theme renders it, the `.vp- ## The dialog in VitePress -The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 3 KB of JavaScript, and the dialog loads the first time it is wanted. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](/docs/script-tag) to `head` instead. +The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 3 KB of JavaScript, and the dialog's stylesheet, 3.5 KB gzipped, which the plugin adds to the site's CSS. The dialog comes prebuilt with its own React, Radix and cmdk, from `ondocs/embed/standalone`, and loads the first time it is wanted: 95.6 KB of JavaScript, gzipped, on the VitePress example. Measured on an Apple M1 Max with `node scripts/js-weight.mjs vitepress`, in headless Chrome, and `gzip -9` of `ondocs/react/styles.css`. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](/docs/script-tag) to `head` instead. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `launcher: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. diff --git a/examples/starlight/src/content/docs/integrations/astro.md b/examples/starlight/src/content/docs/integrations/astro.md index 213c0585..83e9ebb7 100644 --- a/examples/starlight/src/content/docs/integrations/astro.md +++ b/examples/starlight/src/content/docs/integrations/astro.md @@ -12,12 +12,14 @@ Live demo: [these docs as a Starlight site](https://ondocs-starlight.vercel.app) ## Install for Astro -The dialog is a React component, so install React and its two dependencies along with the AI SDK: +Install ondocs and the AI SDK. The dialog comes with its own React, so your site needs none: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` +With ondocs 0.11.0 or earlier, the dialog renders with the site's React: also install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`. + ## Add it to Starlight To add ondocs to Starlight, put the plugin in Starlight's `plugins` array in `astro.config.mjs`: @@ -57,7 +59,9 @@ The Astro integration reads each page's `
`. Choose another part with `cont ## The dialog in Astro -Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. Vite bundles the dialog with your pages, so it shares React with your own islands. With ``, the dialog stays across navigations and citations navigate through the router. +Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. With ``, the dialog stays across navigations and citations navigate through the router. + +The dialog comes prebuilt with its own React, Radix and cmdk, from `ondocs/embed/standalone`, also on a site with React islands of its own. Vite splits it off into a chunk that loads the first time it is wanted: 92.6 KB of JavaScript, gzipped, on the Starlight example. Before then, a page carries only the button and the shortcut, and the dialog's stylesheet, 3.5 KB gzipped, which the plugin adds to the site's CSS. Measured on an Apple M1 Max with `node scripts/js-weight.mjs starlight`, in headless Chrome, and `gzip -9` of `ondocs/react/styles.css`. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `launcher: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. diff --git a/examples/starlight/src/content/docs/integrations/script-tag.md b/examples/starlight/src/content/docs/integrations/script-tag.md index 98726407..bc708586 100644 --- a/examples/starlight/src/content/docs/integrations/script-tag.md +++ b/examples/starlight/src/content/docs/integrations/script-tag.md @@ -86,4 +86,6 @@ From a script, `window.Ondocs.open(question?)` opens it. With `data-manual`, the ## With a bundler -In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed` does the same with your own copy of React. Install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. +In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed/standalone` does the same, with React, Radix and cmdk built into the dialog's chunk, which your bundler splits off to load on first use. Install nothing else, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. The Astro, Starlight and VitePress plugins mount it this way. + +`mountAskDialog(options)` from `ondocs/embed` is the same, rendered with your own copy of React instead, for an app that already loads React: install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk` with it. diff --git a/examples/starlight/src/content/docs/integrations/vitepress.md b/examples/starlight/src/content/docs/integrations/vitepress.md index fc9a4e90..7b2b5dee 100644 --- a/examples/starlight/src/content/docs/integrations/vitepress.md +++ b/examples/starlight/src/content/docs/integrations/vitepress.md @@ -12,12 +12,14 @@ Live demo: [these docs as a VitePress site](https://ondocs-vitepress.vercel.app) ## Install for VitePress -The dialog is a React component, so install React and its two dependencies along with the AI SDK. They load with the dialog, the first time a visitor opens it, and never on the rest of your Vue pages: +Install ondocs and the AI SDK. The dialog comes with its own React, which loads with it the first time a visitor opens it, and never on the rest of your Vue pages: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` +With ondocs 0.11.0 or earlier, the dialog renders with the site's React: also install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`. + ## Add it to .vitepress/config.mts To add ondocs to VitePress, wrap the config in `withOndocs`: @@ -49,7 +51,7 @@ The plugin reads each page's Markdown as the default theme renders it, the `.vp- ## The dialog in VitePress -The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 3 KB of JavaScript, and the dialog loads the first time it is wanted. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](/integrations/script-tag/) to `head` instead. +The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 3 KB of JavaScript, and the dialog's stylesheet, 3.5 KB gzipped, which the plugin adds to the site's CSS. The dialog comes prebuilt with its own React, Radix and cmdk, from `ondocs/embed/standalone`, and loads the first time it is wanted: 95.6 KB of JavaScript, gzipped, on the VitePress example. Measured on an Apple M1 Max with `node scripts/js-weight.mjs vitepress`, in headless Chrome, and `gzip -9` of `ondocs/react/styles.css`. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](/integrations/script-tag/) to `head` instead. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `launcher: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. diff --git a/examples/vitepress/docs/astro.md b/examples/vitepress/docs/astro.md index c7c271d8..4507eaac 100644 --- a/examples/vitepress/docs/astro.md +++ b/examples/vitepress/docs/astro.md @@ -12,12 +12,14 @@ Live demo: [these docs as a Starlight site](https://ondocs-starlight.vercel.app) ## Install for Astro -The dialog is a React component, so install React and its two dependencies along with the AI SDK: +Install ondocs and the AI SDK. The dialog comes with its own React, so your site needs none: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` +With ondocs 0.11.0 or earlier, the dialog renders with the site's React: also install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`. + ## Add it to Starlight To add ondocs to Starlight, put the plugin in Starlight's `plugins` array in `astro.config.mjs`: @@ -57,7 +59,9 @@ The Astro integration reads each page's `
`. Choose another part with `cont ## The dialog in Astro -Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. Vite bundles the dialog with your pages, so it shares React with your own islands. With ``, the dialog stays across navigations and citations navigate through the router. +Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. With ``, the dialog stays across navigations and citations navigate through the router. + +The dialog comes prebuilt with its own React, Radix and cmdk, from `ondocs/embed/standalone`, also on a site with React islands of its own. Vite splits it off into a chunk that loads the first time it is wanted: 92.6 KB of JavaScript, gzipped, on the Starlight example. Before then, a page carries only the button and the shortcut, and the dialog's stylesheet, 3.5 KB gzipped, which the plugin adds to the site's CSS. Measured on an Apple M1 Max with `node scripts/js-weight.mjs starlight`, in headless Chrome, and `gzip -9` of `ondocs/react/styles.css`. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `launcher: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. diff --git a/examples/vitepress/docs/script-tag.md b/examples/vitepress/docs/script-tag.md index af3720c6..053843d4 100644 --- a/examples/vitepress/docs/script-tag.md +++ b/examples/vitepress/docs/script-tag.md @@ -86,4 +86,6 @@ From a script, `window.Ondocs.open(question?)` opens it. With `data-manual`, the ## With a bundler -In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed` does the same with your own copy of React. Install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. +In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed/standalone` does the same, with React, Radix and cmdk built into the dialog's chunk, which your bundler splits off to load on first use. Install nothing else, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. The Astro, Starlight and VitePress plugins mount it this way. + +`mountAskDialog(options)` from `ondocs/embed` is the same, rendered with your own copy of React instead, for an app that already loads React: install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk` with it. diff --git a/examples/vitepress/docs/vitepress.md b/examples/vitepress/docs/vitepress.md index ac2beff8..e2ea275a 100644 --- a/examples/vitepress/docs/vitepress.md +++ b/examples/vitepress/docs/vitepress.md @@ -12,12 +12,14 @@ Live demo: [these docs as a VitePress site](https://ondocs-vitepress.vercel.app) ## Install for VitePress -The dialog is a React component, so install React and its two dependencies along with the AI SDK. They load with the dialog, the first time a visitor opens it, and never on the rest of your Vue pages: +Install ondocs and the AI SDK. The dialog comes with its own React, which loads with it the first time a visitor opens it, and never on the rest of your Vue pages: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` +With ondocs 0.11.0 or earlier, the dialog renders with the site's React: also install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`. + ## Add it to .vitepress/config.mts To add ondocs to VitePress, wrap the config in `withOndocs`: @@ -49,7 +51,7 @@ The plugin reads each page's Markdown as the default theme renders it, the `.vp- ## The dialog in VitePress -The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 3 KB of JavaScript, and the dialog loads the first time it is wanted. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](/script-tag) to `head` instead. +The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 3 KB of JavaScript, and the dialog's stylesheet, 3.5 KB gzipped, which the plugin adds to the site's CSS. The dialog comes prebuilt with its own React, Radix and cmdk, from `ondocs/embed/standalone`, and loads the first time it is wanted: 95.6 KB of JavaScript, gzipped, on the VitePress example. Measured on an Apple M1 Max with `node scripts/js-weight.mjs vitepress`, in headless Chrome, and `gzip -9` of `ondocs/react/styles.css`. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](/script-tag) to `head` instead. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `launcher: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. diff --git a/package.json b/package.json index 4f598eab..60fcc470 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,10 @@ "types": "./dist/embed.d.ts", "default": "./dist/embed.js" }, + "./embed/standalone": { + "types": "./dist/embed.d.ts", + "default": "./dist/embed/standalone.js" + }, "./embed/launcher.css": "./dist/embed/launcher.css", "./embed/button.css": "./dist/embed/button.css", "./docusaurus": { diff --git a/scripts/consumer-site.mjs b/scripts/consumer-site.mjs index 25a09eab..09824734 100644 --- a/scripts/consumer-site.mjs +++ b/scripts/consumer-site.mjs @@ -30,7 +30,10 @@ import { fileURLToPath } from 'node:url'; import { startOpenAIStub } from './openai-stub.mjs'; const MODEL = 'text-embedding-3-small'; -const PEERS = ['ai', '@ai-sdk/openai', '@radix-ui/react-dialog', 'cmdk']; +/** What every site installs with ondocs: the AI SDK, which the index build and the endpoint use. */ +const MODELS = ['ai', '@ai-sdk/openai']; +/** A React site's dialog renders with the site's React, and these two. */ +const PEERS = [...MODELS, '@radix-ui/react-dialog', 'cmdk']; /** The Nextra release whose docs starter the nextra site is, as the Nextra guide was checked on. */ const NEXTRA = '4.6.1'; @@ -359,7 +362,8 @@ export const POST = createAskHandler({ .replace(/\}\)\s*$/, '}))\n'), ); }, - install: [...PEERS, 'react', 'react-dom'], + // The dialog brings its own React, Radix and cmdk, so the guide installs none of them. + install: MODELS, build: [['npm', 'run', 'docs:build']], index: '.vitepress/dist/ask-index.json', llms: '.vitepress/dist', @@ -367,7 +371,7 @@ export const POST = createAskHandler({ linked: true, check: (dir, index) => { const out = join(dir, '.vitepress/dist'); - const problems = []; + const problems = withoutReact(dir); // Each URL in the index is a page the build wrote, as a static host serves it. for (const { url } of index.documents) { const path = decodeURI(url).replace(/^\//, ''); @@ -522,15 +526,29 @@ function starlight(entry) { ), ); }, - install: [...PEERS, 'react', 'react-dom'], + // The dialog brings its own React, Radix and cmdk, so the guide installs none of them. + // starlight-ondocs still names them as peers, which npm installs, so only ondocs/starlight's + // site is checked for them. + install: MODELS, build: [['npx', 'astro', 'build']], index: 'dist/ask-index.json', llms: 'dist', dimensions: 512, linked: true, + ...(entry === 'ondocs/starlight' ? { check: (dir) => withoutReact(dir) } : {}), }; } +/** + * What is wrong with a site that must build without React: any of the packages a React site's + * dialog renders with that it has installed, so its build would not show the dialog needs none. + */ +function withoutReact(dir) { + return ['react', 'react-dom', '@radix-ui/react-dialog', 'cmdk'] + .filter((name) => existsSync(join(dir, 'node_modules', name))) + .map((name) => `${name} is installed, so the build does not show the dialog brings its own`); +} + /** * What is wrong with the links from the built pages to their `.md` copies: each indexed page, * where the build wrote it in `out`, must have `` in diff --git a/scripts/js-weight.mjs b/scripts/js-weight.mjs index e957114f..b745a0fe 100644 --- a/scripts/js-weight.mjs +++ b/scripts/js-weight.mjs @@ -1,6 +1,6 @@ // Measures the JavaScript a docs page of each example loads in headless Chrome, gzipped: before // any interaction, and after the visitor opens the dialog. With --check, fails when a page loads -// more before interaction than its budget. +// more than its budget for either. // // pnpm build && pnpm --filter build // node scripts/js-weight.mjs [docusaurus|starlight|vitepress|next|embed ...] [--check] [--json] @@ -27,6 +27,11 @@ const root = fileURLToPath(new URL('..', import.meta.url)); * little room; the dialog itself loads on first use and is not counted. A framework update, or * the demo's own growth, such as its banner or more pages, can need a budget raised: measure * the site built without ondocs, keeping its theme and pages (`docusaurus=`). + * + * `open` is the budget for what the dialog adds when it opens, with a few KB of room. On a React + * site (Docusaurus, Next.js) that is the dialog, Radix and cmdk; on the others, it brings its own + * React too: the Starlight and VitePress plugins' `ondocs/embed/standalone`, and the script tag's + * `embed-dialog.global.js`. */ const SITES = { docusaurus: { @@ -39,6 +44,8 @@ const SITES = { // site loads its shared code in one chunk, not two, and 173.8 KB, the benchmarks page's // figure. The budget leaves about 4 KB of room, as Starlight's does. budget: 182, + // The dialog, Radix and cmdk, on the site's React: 27.2 KB. + open: 31, }, starlight: { dir: 'examples/starlight/dist', @@ -47,6 +54,8 @@ const SITES = { // Starlight's own 33.7 KB, with the demo's banner and question box, and ondocs's 2.4 KB: // 36.1 KB. budget: 40, + // ondocs/embed/standalone's dialog, with its own React: 92.6 KB. + open: 97, }, vitepress: { dir: 'examples/vitepress/.vitepress/dist', @@ -55,6 +64,8 @@ const SITES = { // VitePress's own 58.7 KB, with the demo's notice and question box, and ondocs's 2.9 KB: // 61.6 KB. budget: 64, + // ondocs/embed/standalone's dialog, with its own React: 95.6 KB. + open: 100, }, next: { next: 'examples/nextjs', @@ -66,6 +77,8 @@ const SITES = { // package.json, which client code reached through the site's constants, left the bundle. launcher: false, budget: 180, + // The dialog, Radix and cmdk, on the site's React: 27.3 KB. + open: 31, }, embed: { dir: null, @@ -73,6 +86,8 @@ const SITES = { key: 'i', // dist/embed.global.js alone: the button, the shortcut and their styles. budget: 4, + // dist/embed-dialog.global.js: the dialog, React and the dialog's styles, 97.8 KB. + open: 102, }, }; @@ -276,6 +291,7 @@ async function measure(name, dir, baseline) { focusReturned, launcherFocusReturned, budgetKB: site.budget, + openBudgetKB: site.open, }; } finally { await browser.close(); @@ -306,7 +322,7 @@ else { continue; } console.log( - `${result.site.padEnd(11)} ${String(result.initialKB).padStart(6)} KB before interaction (budget ${String(result.budgetKB)}), +${String(result.onOpenKB)} KB on open; button ${result.launcher ? 'shown' : 'missing'}; shortcut while loading: input ${result.dialogFocused ? 'focused' : 'NOT focused'}, focus ${result.focusReturned ? 'back on the page' : 'NOT back'} after Escape${result.launcher ? `, ${result.launcherFocusReturned ? 'and back on the button' : 'NOT back on the button'} after a click` : ''}`, + `${result.site.padEnd(11)} ${String(result.initialKB).padStart(6)} KB before interaction (budget ${String(result.budgetKB)}), +${String(result.onOpenKB)} KB on open (budget ${String(result.openBudgetKB)}); button ${result.launcher ? 'shown' : 'missing'}; shortcut while loading: input ${result.dialogFocused ? 'focused' : 'NOT focused'}, focus ${result.focusReturned ? 'back on the page' : 'NOT back'} after Escape${result.launcher ? `, ${result.launcherFocusReturned ? 'and back on the button' : 'NOT back on the button'} after a click` : ''}`, ); } } @@ -314,6 +330,7 @@ const failures = results.filter( (result) => !result.baseline && (result.initialKB > result.budgetKB || + result.onOpenKB > result.openBudgetKB || (SITES[result.site].launcher !== false && !result.launcher) || !result.dialogFocused || !result.focusReturned || @@ -322,7 +339,7 @@ const failures = results.filter( if (check && failures.length > 0) { for (const failure of failures) { console.error( - `✗ ${failure.site}: ${String(failure.initialKB)} KB of JavaScript before interaction (budget ${String(failure.budgetKB)} KB)${failure.launcher || SITES[failure.site].launcher === false ? '' : ', no button'}${failure.dialogFocused ? '' : ', the dialog input did not get focus'}${failure.focusReturned ? '' : ', focus did not come back after Escape'}${failure.launcherFocusReturned === false ? ', focus did not come back to the button' : ''}`, + `✗ ${failure.site}: ${String(failure.initialKB)} KB of JavaScript before interaction (budget ${String(failure.budgetKB)} KB), +${String(failure.onOpenKB)} KB on open (budget ${String(failure.openBudgetKB)} KB)${failure.launcher || SITES[failure.site].launcher === false ? '' : ', no button'}${failure.dialogFocused ? '' : ', the dialog input did not get focus'}${failure.focusReturned ? '' : ', focus did not come back after Escape'}${failure.launcherFocusReturned === false ? ', focus did not come back to the button' : ''}`, ); } process.exit(1); diff --git a/skills/ondocs-setup/SKILL.md b/skills/ondocs-setup/SKILL.md index 468cf70e..f7f0282c 100644 --- a/skills/ondocs-setup/SKILL.md +++ b/skills/ondocs-setup/SKILL.md @@ -30,10 +30,10 @@ Before you install, check two things: Use the project's package manager: `pnpm add` where there is a `pnpm-lock.yaml`, `yarn add` with a `yarn.lock`, `bun add` with a `bun.lock`, and `npm i` otherwise. The packages are the same. -Starlight, Astro and VitePress, whose dialog brings React with it: +Starlight, Astro and VitePress, whose dialog comes with its own React: ```sh -npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i ondocs ai @ai-sdk/openai ``` Docusaurus, Next.js, Fumadocs and Nextra: diff --git a/src/astro/integration.ts b/src/astro/integration.ts index ac902561..c911b63d 100644 --- a/src/astro/integration.ts +++ b/src/astro/integration.ts @@ -21,7 +21,6 @@ import { quietUseClient, readBuiltHtml, resolveMcp, - warnMissingDialogPeers, writeLlmsFiles, writeSiteIndex, type IndexOptions, @@ -354,7 +353,6 @@ export function createIntegration( "astro.config's site", ); mcpUrl = mcp?.url; - warnMissingDialogPeers(logger); // The stylesheets go into every page's CSS: a `page` script's CSS is built but not linked. injectScript( 'page-ssr', @@ -362,7 +360,8 @@ export function createIntegration( .map((stylesheet) => `import ${JSON.stringify(stylesheet)};`) .join('\n'), ); - // Bundled by Vite with each page's scripts, so React is the site's own copy, if it has one. + // Bundled by Vite with each page's scripts. The dialog's chunk brings its own React, Radix + // and cmdk, so the site installs none of them. injectScript( 'page', launcherScript({ @@ -387,7 +386,7 @@ export function createIntegration( ], // Vite's dev server does not see injected scripts when it scans for dependencies to // prebundle, and would find the dialog's on the first page load, then reload it. - optimizeDeps: { include: ['ondocs/embed'] }, + optimizeDeps: { include: ['ondocs/embed/standalone'] }, }, }); }, diff --git a/src/integration/launcher.ts b/src/integration/launcher.ts index 254ab750..cb174086 100644 --- a/src/integration/launcher.ts +++ b/src/integration/launcher.ts @@ -1,5 +1,5 @@ -// The launcher on every page, for a framework whose pages a bundler builds (Vite, in Astro and -// Starlight): a script that mounts it, and the stylesheets it needs. +// The launcher on every page, for a framework whose pages a bundler builds (Vite, in Astro, +// Starlight and VitePress): a script that mounts it, and the stylesheets it needs. import { warnDeprecated } from '../deprecation'; import type { OndocsDialogOptions, OndocsTheme } from '../embed/options'; @@ -102,10 +102,11 @@ export interface LauncherScriptOptions { /** * The source of a browser module that mounts the launcher with `mountAskDialog` from - * `ondocs/embed`: the button and the shortcut, about 1.5 KB gzipped, with the dialog loaded the - * first time it is wanted. For a framework that bundles every page's scripts: serve it as a - * virtual module that each page imports, so the bundler resolves `ondocs/embed` and splits the - * dialog into a chunk of its own. + * `ondocs/embed/standalone`: the button and the shortcut, about 1.5 KB gzipped, with the dialog + * loaded the first time it is wanted. For a framework that bundles every page's scripts: serve it + * as a virtual module that each page imports, so the bundler resolves `ondocs/embed/standalone` + * and keeps the dialog in a chunk of its own. That chunk has React, Radix and cmdk built in, so + * the site needs none of them installed. */ export function launcherScript({ endpoint, @@ -124,7 +125,7 @@ export function launcherScript({ }; if (locales.length === 0) { return [ - "import { mountAskDialog } from 'ondocs/embed';", + "import { mountAskDialog } from 'ondocs/embed/standalone';", `mountAskDialog(${JSON.stringify(mount)});`, ].join('\n'); } @@ -148,7 +149,7 @@ export function launcherScript({ ); const pathUnderBase = `location.pathname.slice(${String(base.replace(/\/+$/, '').length)}).replace(/^\\/+/, '')`; const head = [ - "import { mountAskDialog } from 'ondocs/embed';", + "import { mountAskDialog } from 'ondocs/embed/standalone';", `const options = ${JSON.stringify(mount)};`, `const locales = ${JSON.stringify(byLocale)};`, ]; diff --git a/src/vitepress/index.ts b/src/vitepress/index.ts index 47aed9bd..31148486 100644 --- a/src/vitepress/index.ts +++ b/src/vitepress/index.ts @@ -27,7 +27,6 @@ import { quietUseClient, readBuiltHtml, resolveMcp, - warnMissingDialogPeers, writeLlmsFiles, writeSiteIndex, type IndexOptions, @@ -251,7 +250,6 @@ function isThenable(value: T | Promise): value is Promise { /** `withOndocs` over a config object. */ function wrap(config: Config, options: OndocsOptions): Config { const log = consoleLogger(); - warnMissingDialogPeers(log); const siteUrl = options.siteUrl ?? config.sitemap?.hostname; const mcp = resolveMcp( options.mcp, @@ -316,7 +314,7 @@ function launcherPlugin(endpoint: string, options: OndocsOptions, log: Logger) { name: 'ondocs', // Vite's dev server does not see the module this plugin adds when it scans for dependencies // to prebundle, and would find the dialog's on the first page load, then reload it. - config: () => ({ optimizeDeps: { include: ['ondocs/embed'] } }), + config: () => ({ optimizeDeps: { include: ['ondocs/embed/standalone'] } }), configResolved(resolved: { vitepress?: VitePressSiteConfig }) { site = resolved.vitepress; }, diff --git a/test/astro.test.ts b/test/astro.test.ts index 0be85493..afcd5a32 100644 --- a/test/astro.test.ts +++ b/test/astro.test.ts @@ -349,7 +349,7 @@ describe('ondocs/starlight', () => { ]); expect(script?.[0]).toBe('page'); expect(script?.[1]).toBe( - `import { mountAskDialog } from 'ondocs/embed';\nmountAskDialog(${JSON.stringify({ + `import { mountAskDialog } from 'ondocs/embed/standalone';\nmountAskDialog(${JSON.stringify({ endpoint: 'https://api.example.com/ask', shortcut: 'j', theme: 'light', @@ -362,7 +362,7 @@ describe('ondocs/starlight', () => { expect.objectContaining({ name: 'ondocs:quiet-use-client' }), expect.objectContaining({ name: 'ondocs:virtual-module' }), ], - optimizeDeps: { include: ['ondocs/embed'] }, + optimizeDeps: { include: ['ondocs/embed/standalone'] }, }, }); }); diff --git a/test/integration-entry.test.ts b/test/integration-entry.test.ts index d91bf456..9375bd04 100644 --- a/test/integration-entry.test.ts +++ b/test/integration-entry.test.ts @@ -229,7 +229,7 @@ describe('launcherScript', () => { }), ).toBe( [ - "import { mountAskDialog } from 'ondocs/embed';", + "import { mountAskDialog } from 'ondocs/embed/standalone';", 'mountAskDialog({"endpoint":"/api/ask","suggestions":["Why?"],"title":"Ask Acme"});', ].join('\n'), ); diff --git a/test/standalone-dialog.test.ts b/test/standalone-dialog.test.ts new file mode 100644 index 00000000..599962aa --- /dev/null +++ b/test/standalone-dialog.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +// `ondocs/embed/standalone`, what the Astro, Starlight and VitePress plugins mount the launcher +// with: the dialog's chunk brings React, Radix and cmdk, so a site without React installs none of +// them. Built here as tsdown.config.ts builds it, then read for imports a site would have to +// resolve, and run. +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +import { screen, waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { build } from 'tsdown'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import type { MountAskDialogOptions, MountedAskDialog } from '../src/embed'; +import { launcherScript } from '../src/integration'; +import { launcherModule } from '../src/vitepress'; +import configs from '../tsdown.config'; + +const root = join(import.meta.dirname, '..'); +const read = (file: string) => readFileSync(join(root, file), 'utf8'); + +/** What the dialog renders with, which ondocs/react needs a site to install. */ +const REACT_PACKAGES = ['react', 'react-dom', '@radix-ui/react-dialog', 'cmdk']; + +describe('the setup guides for sites without React', () => { + it('install ondocs and the AI SDK, without React, Radix or cmdk', () => { + const installs = [ + ['README.md', '**Starlight.** `npm i '], + ['examples/nextjs/content/docs/astro.md', 'npm i '], + ['examples/nextjs/content/docs/vitepress.md', 'npm i '], + ].map(([file = '', start = '']) => { + const line = read(file) + .split('\n') + .find((text) => text.startsWith(start)); + const packages = + /npm i ([^`]+)/ + .exec(line ?? '')?.[1] + ?.trim() + .split(/\s+/) ?? []; + return [file, packages.filter((name) => REACT_PACKAGES.includes(name))]; + }); + expect(installs).toEqual([ + ['README.md', []], + ['examples/nextjs/content/docs/astro.md', []], + ['examples/nextjs/content/docs/vitepress.md', []], + ]); + }); +}); + +describe('the plugins’ launcher', () => { + it('mounts with ondocs/embed/standalone, in Astro, Starlight and VitePress', () => { + const imports = (source: string) => + source.split('\n').filter((line) => line.includes('mountAskDialog }')); + expect(imports(launcherScript({ endpoint: '/api/ask' }))).toEqual([ + "import { mountAskDialog } from 'ondocs/embed/standalone';", + ]); + expect(imports(launcherModule('/api/ask', {}, undefined))).toEqual([ + "import { mountAskDialog } from 'ondocs/embed/standalone';", + ]); + }); +}); + +describe('ondocs/embed/standalone', () => { + const ENTRY = 'embed/standalone.js'; + let out = ''; + let files: string[] = []; + /** The dialog's chunk, which the entry imports on first use. */ + let chunk = ''; + const text = (file: string) => readFileSync(join(out, file), 'utf8'); + + beforeAll(async () => { + // Inside the project, where Vitest imports from. + const cache = join(root, 'node_modules', '.cache', 'ondocs'); + mkdirSync(cache, { recursive: true }); + out = mkdtempSync(join(cache, 'standalone-')); + const config = configs.find( + (entry) => + typeof entry.entry === 'object' && + !Array.isArray(entry.entry) && + 'embed/standalone' in entry.entry, + ); + if (!config) throw new Error('tsdown.config.ts builds no embed/standalone'); + await build({ ...config, config: false, cwd: root, outDir: out, logLevel: 'error' }); + files = readdirSync(join(out, 'embed')).map((file) => `embed/${file}`); + chunk = files.find((file) => file !== ENTRY) ?? ''; + // jsdom lacks these; cmdk and Radix call them. + Element.prototype.scrollIntoView = vi.fn(); + if (!('ResizeObserver' in globalThis)) { + globalThis.ResizeObserver = class { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + }; + } + }); + + afterAll(() => { + if (out) rmSync(out, { recursive: true, force: true }); + }); + + it('imports nothing but its own dialog chunk, which imports nothing', () => { + const specifiers = (file: string) => + [ + ...text(file).matchAll( + /\bimport\s*\(\s*[`'"]([^`'"]+)[`'"]\s*\)|\b(?:from|import)\s*[`'"]([^`'"]+)[`'"]/g, + ), + ].map((match) => match[1] ?? match[2]); + expect(files).toHaveLength(2); + expect(files).toContain(ENTRY); + expect(specifiers(ENTRY)).toEqual([`./${chunk.slice('embed/'.length)}`]); + expect(specifiers(chunk)).toEqual([]); + // No require() and no process.env left for a browser bundle to choke on. + for (const file of files) + expect([file, /\brequire\(|process\.env/.test(text(file))]).toEqual([file, false]); + }); + + it('keeps a page to the launcher, and React in the chunk it loads on first use', () => { + const gzipped = (file: string) => gzipSync(text(file), { level: 9 }).length; + expect(gzipped(ENTRY)).toBeLessThan(2.5 * 1024); + expect(text(ENTRY)).not.toContain('ask-dialog'); + // React's own error URL, and the dialog's class names, are in the chunk. + expect(text(chunk)).toContain('react.dev/errors'); + expect(text(chunk)).toContain('ask-dialog'); + }); + + it('opens the dialog and answers, with no React of the page’s own', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + new Response( + [ + { type: 'start', messageMetadata: { refused: false, retrieval: 'keyword' } }, + { type: 'source-url', sourceId: '1', url: '/guides/setup/#install', title: 'Setup' }, + { type: 'text-delta', id: 'a', delta: 'Run the installer [1].' }, + { type: 'finish' }, + ] + .map((part) => `data: ${JSON.stringify(part)}\n\n`) + .join(''), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ), + ); + const { mountAskDialog } = (await import(/* @vite-ignore */ join(out, ENTRY))) as { + mountAskDialog: (options?: MountAskDialogOptions) => MountedAskDialog; + }; + const start = document.createElement('a'); + start.href = '#start'; + start.textContent = 'Start'; + document.body.append(start); + start.focus(); + const ask = mountAskDialog({ suggestions: ['How do I install it?'] }); + try { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /Ask AI/ })); + const input = await screen.findByRole('combobox'); + await waitFor(() => { + expect(document.activeElement).toBe(input); + }); + await user.type(input, 'How do I install it?{Enter}'); + const citation = await screen.findByRole('link', { name: /^Source 1:/ }); + expect(citation.getAttribute('href')).toBe('/guides/setup/#install'); + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe('/api/ask'); + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(document.querySelector('.ask-dialog')).toBeNull(); + }); + expect(screen.getByRole('button', { name: /Ask AI/ }).getAttribute('aria-expanded')).toBe( + 'false', + ); + } finally { + ask.unmount(); + start.remove(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/test/vitepress.test.ts b/test/vitepress.test.ts index eaed84b1..2364cd59 100644 --- a/test/vitepress.test.ts +++ b/test/vitepress.test.ts @@ -147,7 +147,7 @@ describe('the launcher', () => { const { resolveId, load, configResolved, config } = plugin({ dialog: { suggestions: ['Why?'] }, }); - expect(config?.()).toEqual({ optimizeDeps: { include: ['ondocs/embed'] } }); + expect(config?.()).toEqual({ optimizeDeps: { include: ['ondocs/embed/standalone'] } }); const id = resolveId?.('virtual:ondocs/launcher'); expect(id).toBe('\0virtual:ondocs/launcher'); expect(resolveId?.('vue')).toBeNull(); diff --git a/tsdown.config.ts b/tsdown.config.ts index 999e4785..4d00ee46 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -129,4 +129,30 @@ export default defineConfig([ // The first build cleans dist/. clean: false, }, + { + // dist/embed/standalone.js, `ondocs/embed/standalone`: `ondocs/embed` with production React, + // Radix and cmdk built into the dialog's chunk, for a site without React. The Astro, Starlight + // and VitePress plugins mount their launcher with it, so a site installs none of the four, and + // its bundler still splits the dialog off, to load the first time it is wanted. + entry: { 'embed/standalone': 'src/embed/index.ts' }, + outputOptions: { + entryFileNames: '[name].js', + chunkFileNames: 'embed/standalone-[name]-[hash].js', + }, + deps: { alwaysBundle: [/./], onlyBundle: false }, + format: 'esm', + platform: 'browser', + target: 'es2022', + minify: true, + define: { 'process.env.NODE_ENV': '"production"' }, + inputOptions: { + // Radix and cmdk mark their modules "use client", which means nothing once bundled. + onLog: (level, log, handler) => { + if (log.code !== 'MODULE_LEVEL_DIRECTIVE') handler(level, log); + }, + }, + dts: false, + // The first build cleans dist/. + clean: false, + }, ]); From bdb7878cead5932d0ee5e515e800dad734a3e92a Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 08:57:10 +0100 Subject: [PATCH 2/4] test(vitepress): build the fixture site's ondocs/embed/standalone from source The tests run before the package is built, so the fixture aliases ondocs's modules to their source; the launcher now imports ondocs/embed/standalone. --- test/fixtures/vitepress-site/.vitepress/config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/vitepress-site/.vitepress/config.mts b/test/fixtures/vitepress-site/.vitepress/config.mts index 58485669..b5390863 100644 --- a/test/fixtures/vitepress-site/.vitepress/config.mts +++ b/test/fixtures/vitepress-site/.vitepress/config.mts @@ -30,7 +30,7 @@ export default withOndocs( // ondocs's modules from their source, as the tests run before a build. resolve: { alias: [ - { find: /^ondocs\/embed$/, replacement: src('embed/index.ts') }, + { find: /^ondocs\/embed(\/standalone)?$/, replacement: src('embed/index.ts') }, { find: /^ondocs\/react\/styles\.css$/, replacement: src('react/styles.css') }, { find: /^ondocs\/embed\/launcher\.css$/, replacement: src('embed/launcher.css') }, { From 5b9723d01c662d5f831aab25f9b369e91c577918 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 09:31:30 +0100 Subject: [PATCH 3/4] fix(embed): no React for non-React sites in init, the landing page and starlight-ondocs ondocs init still printed react, react-dom, @radix-ui/react-dialog and cmdk for Starlight, Astro and VitePress, the landing page's install step showed them, and starlight-ondocs asked for them as required peers, which npm installs. The plugins mount ondocs/embed/standalone now, so none of them is needed: init lists ondocs, ai and @ai-sdk/openai, and starlight-ondocs asks only for Starlight, Astro and the AI SDK. The docs said openAskDialog from ondocs/embed works in any framework, but on a site without React that import fails the build. ask-dialog.md now says such a site imports it from ondocs/embed/standalone or uses data-ondocs-open, api.md lists the new module and says ondocs/embed needs the site's React, and writing-an-integration.md says what launcherScript mounts. The astro-* deploy recipes and the starlight-ondocs consumer site install no React and check it stays out. The changeset is minor, as the release adds a module. --- .changeset/standalone-dialog.md | 7 +++-- examples/docusaurus/docs/api.md | 31 ++++++++++--------- examples/docusaurus/docs/ask-dialog.md | 2 +- examples/docusaurus/docs/benchmarks.md | 2 +- .../docusaurus/docs/writing-an-integration.md | 4 +-- examples/nextjs/app/(home)/page.tsx | 10 ++---- examples/nextjs/content/docs/api.md | 31 ++++++++++--------- examples/nextjs/content/docs/ask-dialog.md | 2 +- examples/nextjs/content/docs/benchmarks.md | 2 +- .../content/docs/writing-an-integration.md | 4 +-- .../integrations/writing-an-integration.md | 4 +-- .../src/content/docs/reference/api.md | 31 ++++++++++--------- .../src/content/docs/reference/ask-dialog.md | 2 +- .../src/content/docs/reference/benchmarks.md | 2 +- examples/vitepress/docs/api.md | 31 ++++++++++--------- examples/vitepress/docs/ask-dialog.md | 2 +- examples/vitepress/docs/benchmarks.md | 2 +- .../vitepress/docs/writing-an-integration.md | 4 +-- packages/starlight-ondocs/README.md | 2 +- packages/starlight-ondocs/package.json | 4 --- pnpm-lock.yaml | 12 ------- scripts/consumer-site.mjs | 7 ++--- scripts/deploy-recipes.mjs | 11 ++++--- src/astro/integration.ts | 2 +- src/cli/init.ts | 9 +++--- src/starlight/index.ts | 2 +- src/vitepress/index.ts | 2 +- test/__snapshots__/init.test.ts.snap | 8 ++--- test/init.test.ts | 2 ++ test/plugin-packages.test.ts | 19 +++++++++--- test/standalone-dialog.test.ts | 15 +++++++++ 31 files changed, 140 insertions(+), 128 deletions(-) diff --git a/.changeset/standalone-dialog.md b/.changeset/standalone-dialog.md index 9409c9bf..ab1081b8 100644 --- a/.changeset/standalone-dialog.md +++ b/.changeset/standalone-dialog.md @@ -1,7 +1,8 @@ --- -'ondocs': patch +'ondocs': minor +'starlight-ondocs': minor --- -The Astro, Starlight and VitePress plugins no longer need React. Their dialog now comes prebuilt with its own React, Radix and cmdk, from the new `ondocs/embed/standalone`, so a site installs `ondocs`, `ai` and `@ai-sdk/openai` and nothing else. Before, a Vue or Astro site without React had to install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`, or its build failed. The dialog still loads only the first time it is wanted, in a chunk of its own: 92.6 KB gzipped on the Starlight example (93.6 KB before) and 95.6 KB on the VitePress one (96.8 KB before). A site that installed the four packages only for the dialog can remove them. On an Astro site with React islands, the dialog no longer shares their React. +The Astro, Starlight and VitePress plugins no longer need React. Their dialog now comes prebuilt with its own React, Radix and cmdk, from the new `ondocs/embed/standalone`, so a site installs `ondocs`, `ai` and `@ai-sdk/openai` and nothing else. Before, a Vue or Astro site without React had to install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`, or its build failed. `ondocs init` no longer lists them for these sites, and `starlight-ondocs` no longer asks for them as peers, so npm no longer installs them with it. The dialog still loads only the first time it is wanted, in a chunk of its own: 92.6 KB gzipped on the Starlight example (93.6 KB before) and 95.6 KB on the VitePress one (96.8 KB before). A site that installed the four packages only for the dialog can remove them. On an Astro site with React islands, the dialog no longer shares their React. -`ondocs/embed/standalone` has the same `mountAskDialog` and `openAskDialog` as `ondocs/embed`, for an app with a bundler and no React. `launcherScript` from `ondocs/integration` now mounts the launcher with it. +`ondocs/embed/standalone` has the same `mountAskDialog` and `openAskDialog` as `ondocs/embed`, for an app with a bundler and no React. On such a site, import `openAskDialog` from it: `ondocs/embed` renders the dialog with the site's React, so the build fails without React installed. `launcherScript` from `ondocs/integration` now mounts the launcher with it. diff --git a/examples/docusaurus/docs/api.md b/examples/docusaurus/docs/api.md index 1c670478..80a50fff 100644 --- a/examples/docusaurus/docs/api.md +++ b/examples/docusaurus/docs/api.md @@ -6,21 +6,22 @@ description: "The package's imports, the core functions for building and searchi The package has one CLI and these imports. Each is tree-shakeable, and only `ondocs/node`, `ondocs/integration`, the framework plugins and the CLI touch Node.js built-ins, so the rest runs on edge runtimes, in workers and in browsers. -| Import | For | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | -| `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | -| `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](./writing-an-integration.md) | -| `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | -| `ondocs/react` | `AskDialog`, `AskButton`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | -| `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React; `openAskDialog`, to open any page's dialog | -| `ondocs/docusaurus` | The [Docusaurus plugin](./docusaurus.md) | -| `ondocs/astro` | The [Astro integration](./astro.md) | -| `ondocs/starlight` | The [Starlight plugin](./astro.md) | -| `ondocs/vitepress` | The [VitePress plugin](./vitepress.md): `withOndocs` | -| `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | -| `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | -| `ondocs` (bin) | [`ondocs index`, `init`, `dev` and `eval`](./cli.md) | +| Import | For | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | +| `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | +| `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](./writing-an-integration.md) | +| `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | +| `ondocs/react` | `AskDialog`, `AskButton`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | +| `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React; `openAskDialog`, to open any page's dialog. Needs the site's React, `@radix-ui/react-dialog` and `cmdk` | +| `ondocs/embed/standalone` | The same `mountAskDialog` and `openAskDialog`, with React, Radix and cmdk built into the dialog's chunk, for a site without React. The Astro, Starlight and VitePress plugins use it | +| `ondocs/docusaurus` | The [Docusaurus plugin](./docusaurus.md) | +| `ondocs/astro` | The [Astro integration](./astro.md) | +| `ondocs/starlight` | The [Starlight plugin](./astro.md) | +| `ondocs/vitepress` | The [VitePress plugin](./vitepress.md): `withOndocs` | +| `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | +| `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | +| `ondocs` (bin) | [`ondocs index`, `init`, `dev` and `eval`](./cli.md) | The handlers are documented in [The ask endpoint](./ask-endpoint.md) and [MCP server](./mcp.md), and the React components in [The ask dialog](./ask-dialog.md). diff --git a/examples/docusaurus/docs/ask-dialog.md b/examples/docusaurus/docs/ask-dialog.md index 06f6e393..f3ea5ba5 100644 --- a/examples/docusaurus/docs/ask-dialog.md +++ b/examples/docusaurus/docs/ask-dialog.md @@ -50,7 +50,7 @@ Any element with a `data-ondocs-open` attribute opens the dialog the page has: t ``` -From code, `openAskDialog(question?)` from `ondocs/embed` does the same in any framework, and so does `window.Ondocs.open(question?)` on a page with the script tag. A pointer over the element, or focus on it, loads the dialog's code, as on the launcher. The plugins' `navbar` option puts such a button in the navbar for you: see [Docusaurus](./docusaurus.md#ask-ai-in-the-docusaurus-navbar), [Starlight](./astro.md#ask-ai-in-the-starlight-header) and [VitePress](./vitepress.md#ask-ai-in-the-vitepress-nav-bar). For its look on a button of your own, import `ondocs/embed/button.css` and give the button the `ondocs-button` class, with its label in a ``. +From code, `openAskDialog(question?)` from `ondocs/embed` does the same in a React app, and so does `window.Ondocs.open(question?)` on a page with the script tag. On a site without React, such as Astro, Starlight or VitePress, import `openAskDialog(question?)` from `ondocs/embed/standalone` instead, or use `data-ondocs-open`, which needs no import: `ondocs/embed` renders the dialog with the site's own React, so the site's build fails where React is not installed. A pointer over the element, or focus on it, loads the dialog's code, as on the launcher. The plugins' `navbar` option puts such a button in the navbar for you: see [Docusaurus](./docusaurus.md#ask-ai-in-the-docusaurus-navbar), [Starlight](./astro.md#ask-ai-in-the-starlight-header) and [VitePress](./vitepress.md#ask-ai-in-the-vitepress-nav-bar). For its look on a button of your own, import `ondocs/embed/button.css` and give the button the `ondocs-button` class, with its label in a ``. In React, with a controlled `open`, the `question` prop does the same: set both to open the dialog on a question. diff --git a/examples/docusaurus/docs/benchmarks.md b/examples/docusaurus/docs/benchmarks.md index 367158a3..bdcf1685 100644 --- a/examples/docusaurus/docs/benchmarks.md +++ b/examples/docusaurus/docs/benchmarks.md @@ -43,7 +43,7 @@ Each page carries only the dialog's button and shortcut; the dialog loads the fi | Site | The site on its own | With ondocs | When the dialog opens | | -------------------------- | ------------------: | ----------: | --------------------: | | Docusaurus 3.10 | 173.8 KB | 177.7 KB | +26.6 KB | -| Starlight 0.42 | 33.7 KB | 36.1 KB | +92.7 KB | +| Starlight 0.42 | 33.7 KB | 36.1 KB | +92.6 KB | | Script tag on a plain page | 0 | 3.2 KB | +97.1 KB | Starlight's dialog brings its own React, as the script tag's does; Docusaurus's shares the site's. Before the dialog loaded on demand, these pages carried 192.7 KB, 121.2 KB and 91.2 KB. CI measures the example sites the same way, with `node scripts/js-weight.mjs --check`, and fails a change that goes over each site's budget, or that stops a shortcut pressed while the dialog loads from opening it with focus in its input, or focus from coming back when it closes. The handler adds no client code at all. diff --git a/examples/docusaurus/docs/writing-an-integration.md b/examples/docusaurus/docs/writing-an-integration.md index a67e1c1a..88d75dc8 100644 --- a/examples/docusaurus/docs/writing-an-integration.md +++ b/examples/docusaurus/docs/writing-an-integration.md @@ -128,13 +128,13 @@ With `linkPages`, it also links each page to its copy, with ``. With Vite, add `quietUseClient()` to its plugins, which blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. The Astro integration and the VitePress plugin do this; the site installs React, `@radix-ui/react-dialog` and `cmdk` for the dialog. +- **A framework that bundles each page's scripts**, with Vite, Rollup or webpack: `launcherScript` returns the source of a module that mounts the launcher with `mountAskDialog` from `ondocs/embed/standalone`, about 1.5 KB gzipped, with the dialog in a chunk of its own. React, Radix and cmdk are built into that chunk, so the site installs none of them. Serve it as a virtual module every page imports, with `LAUNCHER_STYLESHEETS`, the dialog's and the launcher's stylesheets. Give it `locales` and `base`, and each page under a locale's path prefix gets that locale's labels and sends its locale; with `spa: true`, for a site that moves between pages without loading them, it mounts again when a navigation changes ``. The Astro integration and the VitePress plugin do this. With Vite, they also add `quietUseClient()` to its plugins, for a site that bundles `ondocs/react` itself: it blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. - **A React framework**: render `AskDialog` from `ondocs/react` with `launcher`, as in the [Next.js guide](./nextjs.md). The Docusaurus plugin's theme does the same. - **Anything else**: the [script tag](./script-tag.md), which brings the dialog with its own copy of React. `dialogEndpoint(options.endpoint)` is where the dialog posts: the `ASK_ENDPOINT` environment variable when it is set, so `ASK_ENDPOINT=http://localhost:8787/api/ask` points any site at [`ondocs dev`](./local-development.md), else the option, else `/api/ask`. -While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. +While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` is for a dialog that renders with the site's React, as the Docusaurus plugin's does: it warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. A launcher from `launcherScript` needs none of them. ## Point agents at the MCP endpoint diff --git a/examples/nextjs/app/(home)/page.tsx b/examples/nextjs/app/(home)/page.tsx index 88152d7b..be1021d7 100644 --- a/examples/nextjs/app/(home)/page.tsx +++ b/examples/nextjs/app/(home)/page.tsx @@ -134,10 +134,7 @@ async function setupTabs(): Promise { steps: [ { title: 'Install', - code: await codeBlock( - 'npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk', - 'sh', - ), + code: await codeBlock('npm i ondocs ai @ai-sdk/openai', 'sh'), }, { title: 'Add the plugin', @@ -181,10 +178,7 @@ async function setupTabs(): Promise { steps: [ { title: 'Install', - code: await codeBlock( - 'npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk', - 'sh', - ), + code: await codeBlock('npm i ondocs ai @ai-sdk/openai', 'sh'), }, { title: 'Wrap the config', diff --git a/examples/nextjs/content/docs/api.md b/examples/nextjs/content/docs/api.md index 9b9d9800..f6188e64 100644 --- a/examples/nextjs/content/docs/api.md +++ b/examples/nextjs/content/docs/api.md @@ -8,21 +8,22 @@ order: 29 The package has one CLI and these imports. Each is tree-shakeable, and only `ondocs/node`, `ondocs/integration`, the framework plugins and the CLI touch Node.js built-ins, so the rest runs on edge runtimes, in workers and in browsers. -| Import | For | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | -| `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | -| `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](/docs/writing-an-integration) | -| `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | -| `ondocs/react` | `AskDialog`, `AskButton`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | -| `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React; `openAskDialog`, to open any page's dialog | -| `ondocs/docusaurus` | The [Docusaurus plugin](/docs/docusaurus) | -| `ondocs/astro` | The [Astro integration](/docs/astro) | -| `ondocs/starlight` | The [Starlight plugin](/docs/astro) | -| `ondocs/vitepress` | The [VitePress plugin](/docs/vitepress): `withOndocs` | -| `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | -| `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | -| `ondocs` (bin) | [`ondocs index`, `init`, `dev` and `eval`](/docs/cli) | +| Import | For | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | +| `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | +| `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](/docs/writing-an-integration) | +| `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | +| `ondocs/react` | `AskDialog`, `AskButton`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | +| `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React; `openAskDialog`, to open any page's dialog. Needs the site's React, `@radix-ui/react-dialog` and `cmdk` | +| `ondocs/embed/standalone` | The same `mountAskDialog` and `openAskDialog`, with React, Radix and cmdk built into the dialog's chunk, for a site without React. The Astro, Starlight and VitePress plugins use it | +| `ondocs/docusaurus` | The [Docusaurus plugin](/docs/docusaurus) | +| `ondocs/astro` | The [Astro integration](/docs/astro) | +| `ondocs/starlight` | The [Starlight plugin](/docs/astro) | +| `ondocs/vitepress` | The [VitePress plugin](/docs/vitepress): `withOndocs` | +| `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | +| `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | +| `ondocs` (bin) | [`ondocs index`, `init`, `dev` and `eval`](/docs/cli) | The handlers are documented in [The ask endpoint](/docs/ask-endpoint) and [MCP server](/docs/mcp), and the React components in [The ask dialog](/docs/ask-dialog). diff --git a/examples/nextjs/content/docs/ask-dialog.md b/examples/nextjs/content/docs/ask-dialog.md index 7f18f250..106ebe9b 100644 --- a/examples/nextjs/content/docs/ask-dialog.md +++ b/examples/nextjs/content/docs/ask-dialog.md @@ -52,7 +52,7 @@ Any element with a `data-ondocs-open` attribute opens the dialog the page has: t ``` -From code, `openAskDialog(question?)` from `ondocs/embed` does the same in any framework, and so does `window.Ondocs.open(question?)` on a page with the script tag. A pointer over the element, or focus on it, loads the dialog's code, as on the launcher. The plugins' `navbar` option puts such a button in the navbar for you: see [Docusaurus](/docs/docusaurus#ask-ai-in-the-docusaurus-navbar), [Starlight](/docs/astro#ask-ai-in-the-starlight-header) and [VitePress](/docs/vitepress#ask-ai-in-the-vitepress-nav-bar). For its look on a button of your own, import `ondocs/embed/button.css` and give the button the `ondocs-button` class, with its label in a ``. +From code, `openAskDialog(question?)` from `ondocs/embed` does the same in a React app, and so does `window.Ondocs.open(question?)` on a page with the script tag. On a site without React, such as Astro, Starlight or VitePress, import `openAskDialog(question?)` from `ondocs/embed/standalone` instead, or use `data-ondocs-open`, which needs no import: `ondocs/embed` renders the dialog with the site's own React, so the site's build fails where React is not installed. A pointer over the element, or focus on it, loads the dialog's code, as on the launcher. The plugins' `navbar` option puts such a button in the navbar for you: see [Docusaurus](/docs/docusaurus#ask-ai-in-the-docusaurus-navbar), [Starlight](/docs/astro#ask-ai-in-the-starlight-header) and [VitePress](/docs/vitepress#ask-ai-in-the-vitepress-nav-bar). For its look on a button of your own, import `ondocs/embed/button.css` and give the button the `ondocs-button` class, with its label in a ``. In React, with a controlled `open`, the `question` prop does the same: set both to open the dialog on a question. diff --git a/examples/nextjs/content/docs/benchmarks.md b/examples/nextjs/content/docs/benchmarks.md index 9a147772..3c70962b 100644 --- a/examples/nextjs/content/docs/benchmarks.md +++ b/examples/nextjs/content/docs/benchmarks.md @@ -44,7 +44,7 @@ Each page carries only the dialog's button and shortcut; the dialog loads the fi | Site | The site on its own | With ondocs | When the dialog opens | | -------------------------- | ------------------: | ----------: | --------------------: | | Docusaurus 3.10 | 173.8 KB | 177.7 KB | +26.6 KB | -| Starlight 0.42 | 33.7 KB | 36.1 KB | +92.7 KB | +| Starlight 0.42 | 33.7 KB | 36.1 KB | +92.6 KB | | Script tag on a plain page | 0 | 3.2 KB | +97.1 KB | Starlight's dialog brings its own React, as the script tag's does; Docusaurus's shares the site's. Before the dialog loaded on demand, these pages carried 192.7 KB, 121.2 KB and 91.2 KB. CI measures the example sites the same way, with `node scripts/js-weight.mjs --check`, and fails a change that goes over each site's budget, or that stops a shortcut pressed while the dialog loads from opening it with focus in its input, or focus from coming back when it closes. The handler adds no client code at all. diff --git a/examples/nextjs/content/docs/writing-an-integration.md b/examples/nextjs/content/docs/writing-an-integration.md index b19887a2..9c121cc6 100644 --- a/examples/nextjs/content/docs/writing-an-integration.md +++ b/examples/nextjs/content/docs/writing-an-integration.md @@ -129,13 +129,13 @@ With `linkPages`, it also links each page to its copy, with ``. With Vite, add `quietUseClient()` to its plugins, which blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. The Astro integration and the VitePress plugin do this; the site installs React, `@radix-ui/react-dialog` and `cmdk` for the dialog. +- **A framework that bundles each page's scripts**, with Vite, Rollup or webpack: `launcherScript` returns the source of a module that mounts the launcher with `mountAskDialog` from `ondocs/embed/standalone`, about 1.5 KB gzipped, with the dialog in a chunk of its own. React, Radix and cmdk are built into that chunk, so the site installs none of them. Serve it as a virtual module every page imports, with `LAUNCHER_STYLESHEETS`, the dialog's and the launcher's stylesheets. Give it `locales` and `base`, and each page under a locale's path prefix gets that locale's labels and sends its locale; with `spa: true`, for a site that moves between pages without loading them, it mounts again when a navigation changes ``. The Astro integration and the VitePress plugin do this. With Vite, they also add `quietUseClient()` to its plugins, for a site that bundles `ondocs/react` itself: it blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. - **A React framework**: render `AskDialog` from `ondocs/react` with `launcher`, as in the [Next.js guide](/docs/nextjs). The Docusaurus plugin's theme does the same. - **Anything else**: the [script tag](/docs/script-tag), which brings the dialog with its own copy of React. `dialogEndpoint(options.endpoint)` is where the dialog posts: the `ASK_ENDPOINT` environment variable when it is set, so `ASK_ENDPOINT=http://localhost:8787/api/ask` points any site at [`ondocs dev`](/docs/local-development), else the option, else `/api/ask`. -While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. +While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` is for a dialog that renders with the site's React, as the Docusaurus plugin's does: it warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. A launcher from `launcherScript` needs none of them. ## Point agents at the MCP endpoint diff --git a/examples/starlight/src/content/docs/integrations/writing-an-integration.md b/examples/starlight/src/content/docs/integrations/writing-an-integration.md index fe9481d9..a96acd45 100644 --- a/examples/starlight/src/content/docs/integrations/writing-an-integration.md +++ b/examples/starlight/src/content/docs/integrations/writing-an-integration.md @@ -130,13 +130,13 @@ With `linkPages`, it also links each page to its copy, with ``. With Vite, add `quietUseClient()` to its plugins, which blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. The Astro integration and the VitePress plugin do this; the site installs React, `@radix-ui/react-dialog` and `cmdk` for the dialog. +- **A framework that bundles each page's scripts**, with Vite, Rollup or webpack: `launcherScript` returns the source of a module that mounts the launcher with `mountAskDialog` from `ondocs/embed/standalone`, about 1.5 KB gzipped, with the dialog in a chunk of its own. React, Radix and cmdk are built into that chunk, so the site installs none of them. Serve it as a virtual module every page imports, with `LAUNCHER_STYLESHEETS`, the dialog's and the launcher's stylesheets. Give it `locales` and `base`, and each page under a locale's path prefix gets that locale's labels and sends its locale; with `spa: true`, for a site that moves between pages without loading them, it mounts again when a navigation changes ``. The Astro integration and the VitePress plugin do this. With Vite, they also add `quietUseClient()` to its plugins, for a site that bundles `ondocs/react` itself: it blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. - **A React framework**: render `AskDialog` from `ondocs/react` with `launcher`, as in the [Next.js guide](/integrations/nextjs/). The Docusaurus plugin's theme does the same. - **Anything else**: the [script tag](/integrations/script-tag/), which brings the dialog with its own copy of React. `dialogEndpoint(options.endpoint)` is where the dialog posts: the `ASK_ENDPOINT` environment variable when it is set, so `ASK_ENDPOINT=http://localhost:8787/api/ask` points any site at [`ondocs dev`](/get-started/local-development/), else the option, else `/api/ask`. -While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. +While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` is for a dialog that renders with the site's React, as the Docusaurus plugin's does: it warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. A launcher from `launcherScript` needs none of them. ## Point agents at the MCP endpoint diff --git a/examples/starlight/src/content/docs/reference/api.md b/examples/starlight/src/content/docs/reference/api.md index c6d06f73..2ec25667 100644 --- a/examples/starlight/src/content/docs/reference/api.md +++ b/examples/starlight/src/content/docs/reference/api.md @@ -8,21 +8,22 @@ sidebar: The package has one CLI and these imports. Each is tree-shakeable, and only `ondocs/node`, `ondocs/integration`, the framework plugins and the CLI touch Node.js built-ins, so the rest runs on edge runtimes, in workers and in browsers. -| Import | For | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | -| `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | -| `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](/integrations/writing-an-integration/) | -| `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | -| `ondocs/react` | `AskDialog`, `AskButton`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | -| `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React; `openAskDialog`, to open any page's dialog | -| `ondocs/docusaurus` | The [Docusaurus plugin](/integrations/docusaurus/) | -| `ondocs/astro` | The [Astro integration](/integrations/astro/) | -| `ondocs/starlight` | The [Starlight plugin](/integrations/astro/) | -| `ondocs/vitepress` | The [VitePress plugin](/integrations/vitepress/): `withOndocs` | -| `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | -| `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | -| `ondocs` (bin) | [`ondocs index`, `init`, `dev` and `eval`](/reference/cli/) | +| Import | For | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | +| `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | +| `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](/integrations/writing-an-integration/) | +| `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | +| `ondocs/react` | `AskDialog`, `AskButton`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | +| `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React; `openAskDialog`, to open any page's dialog. Needs the site's React, `@radix-ui/react-dialog` and `cmdk` | +| `ondocs/embed/standalone` | The same `mountAskDialog` and `openAskDialog`, with React, Radix and cmdk built into the dialog's chunk, for a site without React. The Astro, Starlight and VitePress plugins use it | +| `ondocs/docusaurus` | The [Docusaurus plugin](/integrations/docusaurus/) | +| `ondocs/astro` | The [Astro integration](/integrations/astro/) | +| `ondocs/starlight` | The [Starlight plugin](/integrations/astro/) | +| `ondocs/vitepress` | The [VitePress plugin](/integrations/vitepress/): `withOndocs` | +| `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | +| `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | +| `ondocs` (bin) | [`ondocs index`, `init`, `dev` and `eval`](/reference/cli/) | The handlers are documented in [The ask endpoint](/reference/ask-endpoint/) and [MCP server](/integrations/mcp/), and the React components in [The ask dialog](/reference/ask-dialog/). diff --git a/examples/starlight/src/content/docs/reference/ask-dialog.md b/examples/starlight/src/content/docs/reference/ask-dialog.md index 572d1887..13c0dbfa 100644 --- a/examples/starlight/src/content/docs/reference/ask-dialog.md +++ b/examples/starlight/src/content/docs/reference/ask-dialog.md @@ -52,7 +52,7 @@ Any element with a `data-ondocs-open` attribute opens the dialog the page has: t ``` -From code, `openAskDialog(question?)` from `ondocs/embed` does the same in any framework, and so does `window.Ondocs.open(question?)` on a page with the script tag. A pointer over the element, or focus on it, loads the dialog's code, as on the launcher. The plugins' `navbar` option puts such a button in the navbar for you: see [Docusaurus](/integrations/docusaurus/#ask-ai-in-the-docusaurus-navbar), [Starlight](/integrations/astro/#ask-ai-in-the-starlight-header) and [VitePress](/integrations/vitepress/#ask-ai-in-the-vitepress-nav-bar). For its look on a button of your own, import `ondocs/embed/button.css` and give the button the `ondocs-button` class, with its label in a ``. +From code, `openAskDialog(question?)` from `ondocs/embed` does the same in a React app, and so does `window.Ondocs.open(question?)` on a page with the script tag. On a site without React, such as Astro, Starlight or VitePress, import `openAskDialog(question?)` from `ondocs/embed/standalone` instead, or use `data-ondocs-open`, which needs no import: `ondocs/embed` renders the dialog with the site's own React, so the site's build fails where React is not installed. A pointer over the element, or focus on it, loads the dialog's code, as on the launcher. The plugins' `navbar` option puts such a button in the navbar for you: see [Docusaurus](/integrations/docusaurus/#ask-ai-in-the-docusaurus-navbar), [Starlight](/integrations/astro/#ask-ai-in-the-starlight-header) and [VitePress](/integrations/vitepress/#ask-ai-in-the-vitepress-nav-bar). For its look on a button of your own, import `ondocs/embed/button.css` and give the button the `ondocs-button` class, with its label in a ``. In React, with a controlled `open`, the `question` prop does the same: set both to open the dialog on a question. diff --git a/examples/starlight/src/content/docs/reference/benchmarks.md b/examples/starlight/src/content/docs/reference/benchmarks.md index b2af9fe6..ac204e45 100644 --- a/examples/starlight/src/content/docs/reference/benchmarks.md +++ b/examples/starlight/src/content/docs/reference/benchmarks.md @@ -45,7 +45,7 @@ Each page carries only the dialog's button and shortcut; the dialog loads the fi | Site | The site on its own | With ondocs | When the dialog opens | | -------------------------- | ------------------: | ----------: | --------------------: | | Docusaurus 3.10 | 173.8 KB | 177.7 KB | +26.6 KB | -| Starlight 0.42 | 33.7 KB | 36.1 KB | +92.7 KB | +| Starlight 0.42 | 33.7 KB | 36.1 KB | +92.6 KB | | Script tag on a plain page | 0 | 3.2 KB | +97.1 KB | Starlight's dialog brings its own React, as the script tag's does; Docusaurus's shares the site's. Before the dialog loaded on demand, these pages carried 192.7 KB, 121.2 KB and 91.2 KB. CI measures the example sites the same way, with `node scripts/js-weight.mjs --check`, and fails a change that goes over each site's budget, or that stops a shortcut pressed while the dialog loads from opening it with focus in its input, or focus from coming back when it closes. The handler adds no client code at all. diff --git a/examples/vitepress/docs/api.md b/examples/vitepress/docs/api.md index 7b26f2f7..dd722562 100644 --- a/examples/vitepress/docs/api.md +++ b/examples/vitepress/docs/api.md @@ -8,21 +8,22 @@ description: "The package's imports, the core functions for building and searchi The package has one CLI and these imports. Each is tree-shakeable, and only `ondocs/node`, `ondocs/integration`, the framework plugins and the CLI touch Node.js built-ins, so the rest runs on edge runtimes, in workers and in browsers. -| Import | For | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | -| `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | -| `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](/writing-an-integration) | -| `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | -| `ondocs/react` | `AskDialog`, `AskButton`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | -| `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React; `openAskDialog`, to open any page's dialog | -| `ondocs/docusaurus` | The [Docusaurus plugin](/docusaurus) | -| `ondocs/astro` | The [Astro integration](/astro) | -| `ondocs/starlight` | The [Starlight plugin](/astro) | -| `ondocs/vitepress` | The [VitePress plugin](/vitepress): `withOndocs` | -| `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | -| `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | -| `ondocs` (bin) | [`ondocs index`, `init`, `dev` and `eval`](/cli) | +| Import | For | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | +| `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | +| `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](/writing-an-integration) | +| `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | +| `ondocs/react` | `AskDialog`, `AskButton`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | +| `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React; `openAskDialog`, to open any page's dialog. Needs the site's React, `@radix-ui/react-dialog` and `cmdk` | +| `ondocs/embed/standalone` | The same `mountAskDialog` and `openAskDialog`, with React, Radix and cmdk built into the dialog's chunk, for a site without React. The Astro, Starlight and VitePress plugins use it | +| `ondocs/docusaurus` | The [Docusaurus plugin](/docusaurus) | +| `ondocs/astro` | The [Astro integration](/astro) | +| `ondocs/starlight` | The [Starlight plugin](/astro) | +| `ondocs/vitepress` | The [VitePress plugin](/vitepress): `withOndocs` | +| `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | +| `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | +| `ondocs` (bin) | [`ondocs index`, `init`, `dev` and `eval`](/cli) | The handlers are documented in [The ask endpoint](/ask-endpoint) and [MCP server](/mcp), and the React components in [The ask dialog](/ask-dialog). diff --git a/examples/vitepress/docs/ask-dialog.md b/examples/vitepress/docs/ask-dialog.md index 82277c48..1dd446fe 100644 --- a/examples/vitepress/docs/ask-dialog.md +++ b/examples/vitepress/docs/ask-dialog.md @@ -52,7 +52,7 @@ Any element with a `data-ondocs-open` attribute opens the dialog the page has: t ``` -From code, `openAskDialog(question?)` from `ondocs/embed` does the same in any framework, and so does `window.Ondocs.open(question?)` on a page with the script tag. A pointer over the element, or focus on it, loads the dialog's code, as on the launcher. The plugins' `navbar` option puts such a button in the navbar for you: see [Docusaurus](/docusaurus#ask-ai-in-the-docusaurus-navbar), [Starlight](/astro#ask-ai-in-the-starlight-header) and [VitePress](/vitepress#ask-ai-in-the-vitepress-nav-bar). For its look on a button of your own, import `ondocs/embed/button.css` and give the button the `ondocs-button` class, with its label in a ``. +From code, `openAskDialog(question?)` from `ondocs/embed` does the same in a React app, and so does `window.Ondocs.open(question?)` on a page with the script tag. On a site without React, such as Astro, Starlight or VitePress, import `openAskDialog(question?)` from `ondocs/embed/standalone` instead, or use `data-ondocs-open`, which needs no import: `ondocs/embed` renders the dialog with the site's own React, so the site's build fails where React is not installed. A pointer over the element, or focus on it, loads the dialog's code, as on the launcher. The plugins' `navbar` option puts such a button in the navbar for you: see [Docusaurus](/docusaurus#ask-ai-in-the-docusaurus-navbar), [Starlight](/astro#ask-ai-in-the-starlight-header) and [VitePress](/vitepress#ask-ai-in-the-vitepress-nav-bar). For its look on a button of your own, import `ondocs/embed/button.css` and give the button the `ondocs-button` class, with its label in a ``. In React, with a controlled `open`, the `question` prop does the same: set both to open the dialog on a question. diff --git a/examples/vitepress/docs/benchmarks.md b/examples/vitepress/docs/benchmarks.md index 31eb1e3b..6d3c6649 100644 --- a/examples/vitepress/docs/benchmarks.md +++ b/examples/vitepress/docs/benchmarks.md @@ -45,7 +45,7 @@ Each page carries only the dialog's button and shortcut; the dialog loads the fi | Site | The site on its own | With ondocs | When the dialog opens | | -------------------------- | ------------------: | ----------: | --------------------: | | Docusaurus 3.10 | 173.8 KB | 177.7 KB | +26.6 KB | -| Starlight 0.42 | 33.7 KB | 36.1 KB | +92.7 KB | +| Starlight 0.42 | 33.7 KB | 36.1 KB | +92.6 KB | | Script tag on a plain page | 0 | 3.2 KB | +97.1 KB | Starlight's dialog brings its own React, as the script tag's does; Docusaurus's shares the site's. Before the dialog loaded on demand, these pages carried 192.7 KB, 121.2 KB and 91.2 KB. CI measures the example sites the same way, with `node scripts/js-weight.mjs --check`, and fails a change that goes over each site's budget, or that stops a shortcut pressed while the dialog loads from opening it with focus in its input, or focus from coming back when it closes. The handler adds no client code at all. diff --git a/examples/vitepress/docs/writing-an-integration.md b/examples/vitepress/docs/writing-an-integration.md index 63829cd7..cb23454c 100644 --- a/examples/vitepress/docs/writing-an-integration.md +++ b/examples/vitepress/docs/writing-an-integration.md @@ -130,13 +130,13 @@ With `linkPages`, it also links each page to its copy, with ``. With Vite, add `quietUseClient()` to its plugins, which blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. The Astro integration and the VitePress plugin do this; the site installs React, `@radix-ui/react-dialog` and `cmdk` for the dialog. +- **A framework that bundles each page's scripts**, with Vite, Rollup or webpack: `launcherScript` returns the source of a module that mounts the launcher with `mountAskDialog` from `ondocs/embed/standalone`, about 1.5 KB gzipped, with the dialog in a chunk of its own. React, Radix and cmdk are built into that chunk, so the site installs none of them. Serve it as a virtual module every page imports, with `LAUNCHER_STYLESHEETS`, the dialog's and the launcher's stylesheets. Give it `locales` and `base`, and each page under a locale's path prefix gets that locale's labels and sends its locale; with `spa: true`, for a site that moves between pages without loading them, it mounts again when a navigation changes ``. The Astro integration and the VitePress plugin do this. With Vite, they also add `quietUseClient()` to its plugins, for a site that bundles `ondocs/react` itself: it blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. - **A React framework**: render `AskDialog` from `ondocs/react` with `launcher`, as in the [Next.js guide](/nextjs). The Docusaurus plugin's theme does the same. - **Anything else**: the [script tag](/script-tag), which brings the dialog with its own copy of React. `dialogEndpoint(options.endpoint)` is where the dialog posts: the `ASK_ENDPOINT` environment variable when it is set, so `ASK_ENDPOINT=http://localhost:8787/api/ask` points any site at [`ondocs dev`](/local-development), else the option, else `/api/ask`. -While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. +While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` is for a dialog that renders with the site's React, as the Docusaurus plugin's does: it warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. A launcher from `launcherScript` needs none of them. ## Point agents at the MCP endpoint diff --git a/packages/starlight-ondocs/README.md b/packages/starlight-ondocs/README.md index e277131a..83ba2017 100644 --- a/packages/starlight-ondocs/README.md +++ b/packages/starlight-ondocs/README.md @@ -5,7 +5,7 @@ The [ondocs](https://github.com/dgesteves/ondocs) Starlight plugin, under the na This package only re-exports `ondocs/starlight`, at the same version. Everything is documented on [the ondocs website](https://ondocs.vercel.app/docs/astro). ```sh -npm i starlight-ondocs ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk +npm i starlight-ondocs ondocs ai @ai-sdk/openai ``` Install `ondocs` with it, as the line above does: your project needs it itself. The plugin adds `ondocs/...` modules to your pages, the endpoint you deploy imports `ondocs/server`, and the `ondocs` command (`npx ondocs init`) comes from it. npm finds it as this package's dependency, but pnpm gives a project only its direct dependencies, so without it `astro build` stops and says to install it. The two are released together: keep them at the same version. diff --git a/packages/starlight-ondocs/package.json b/packages/starlight-ondocs/package.json index 349a5e8f..3229b29f 100644 --- a/packages/starlight-ondocs/package.json +++ b/packages/starlight-ondocs/package.json @@ -48,10 +48,6 @@ "peerDependencies": { "@astrojs/starlight": ">=0.32.0", "astro": "^5.0.0 || ^6.0.0 || ^7.0.0", - "react": "^18.3.0 || ^19.0.0", - "react-dom": "^18.3.0 || ^19.0.0", - "@radix-ui/react-dialog": "^1.1.23", - "cmdk": "^1.1.1", "ai": "^7.0.95", "@ai-sdk/openai": ">=4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05732c85..959a372f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -510,27 +510,15 @@ importers: '@astrojs/starlight': specifier: '>=0.32.0' version: 0.42.6(astro@7.3.8(@types/node@24.19.2)(esbuild@0.28.2)(jiti@1.21.7)(terser@5.51.2)(yaml@2.9.1))(supports-color@7.2.0)(typescript@6.0.3) - '@radix-ui/react-dialog': - specifier: ^1.1.23 - version: 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) ai: specifier: ^7.0.95 version: 7.0.139(zod@4.6.6) astro: specifier: ^5.0.0 || ^6.0.0 || ^7.0.0 version: 7.3.8(@types/node@24.19.2)(esbuild@0.28.2)(jiti@1.21.7)(terser@5.51.2)(yaml@2.9.1) - cmdk: - specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) ondocs: specifier: workspace:* version: link:../.. - react: - specifier: ^18.3.0 || ^19.0.0 - version: 19.3.0 - react-dom: - specifier: ^18.3.0 || ^19.0.0 - version: 19.3.0(react@19.3.0) packages: diff --git a/scripts/consumer-site.mjs b/scripts/consumer-site.mjs index 09824734..dc605758 100644 --- a/scripts/consumer-site.mjs +++ b/scripts/consumer-site.mjs @@ -526,16 +526,15 @@ function starlight(entry) { ), ); }, - // The dialog brings its own React, Radix and cmdk, so the guide installs none of them. - // starlight-ondocs still names them as peers, which npm installs, so only ondocs/starlight's - // site is checked for them. + // The dialog brings its own React, Radix and cmdk, so the guide installs none of them, and + // starlight-ondocs asks for none of them as peers, which npm would install. install: MODELS, build: [['npx', 'astro', 'build']], index: 'dist/ask-index.json', llms: 'dist', dimensions: 512, linked: true, - ...(entry === 'ondocs/starlight' ? { check: (dir) => withoutReact(dir) } : {}), + check: (dir) => withoutReact(dir), }; } diff --git a/scripts/deploy-recipes.mjs b/scripts/deploy-recipes.mjs index ee4ae932..fa784927 100644 --- a/scripts/deploy-recipes.mjs +++ b/scripts/deploy-recipes.mjs @@ -531,6 +531,7 @@ async function astroRecipe() { text.replace(/^---\n/, '---\nlayout: ../../layouts/Doc.astro\n'), ); } + // No React, Radix or cmdk: the integration's dialog brings its own, as the Astro guide says. await run('npm', [ 'install', '--no-audit', @@ -540,11 +541,13 @@ async function astroRecipe() { '@ai-sdk/openai', VERSIONS.astro, recipe.adapter, - 'react', - 'react-dom', - '@radix-ui/react-dialog', - 'cmdk', ]); + for (const name of ['react', 'react-dom', '@radix-ui/react-dialog', 'cmdk']) { + expect( + !existsSync(join(dir, 'node_modules', name)), + `${name} is installed, so the build does not show the dialog brings its own`, + ); + } const before = readdirSync(dir).sort().join(); await run('npx', ['ondocs', 'init']); expect( diff --git a/src/astro/integration.ts b/src/astro/integration.ts index c911b63d..45e4c10e 100644 --- a/src/astro/integration.ts +++ b/src/astro/integration.ts @@ -38,7 +38,7 @@ import type { BuiltFiles, RouteSettings } from './route'; /** What the dialog shows in one locale, over the dialog's own options. */ export type OndocsLocaleDialogOptions = LocaleDialogOptions; -/** What the dialog shows; passed to `mountAskDialog` from `ondocs/embed`. */ +/** What the dialog shows; passed to `mountAskDialog` from `ondocs/embed/standalone`. */ export interface OndocsDialogOptions extends DialogOptions { /** * The dialog's accessible name. Default "Ask this site", or "Ask {site title}" in Starlight, or diff --git a/src/cli/init.ts b/src/cli/init.ts index 204218bd..d95a5ff4 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -603,12 +603,11 @@ function install(p: Project, packages: string[], dev = false): string | null { * import ondocs/server and @ai-sdk/openai. */ function sitePackages(site: Site, openai = true, functions = false): string[] { + // A React site's dialog renders with the site's React, and these two. The Starlight, Astro and + // VitePress plugins mount ondocs/embed/standalone, whose dialog brings its own React, Radix and + // cmdk, as the script tag on a static site does. const dialog = - site.kind === 'starlight' || site.kind === 'astro' || site.kind === 'vitepress' - ? ['react', 'react-dom', '@radix-ui/react-dialog', 'cmdk'] - : site.kind === 'static' - ? [] - : ['@radix-ui/react-dialog', 'cmdk']; + site.kind === 'docusaurus' || site.kind === 'next' ? ['@radix-ui/react-dialog', 'cmdk'] : []; // The plugins embed the pages with OpenAI when OPENAI_API_KEY is set as the site builds. A // static site is indexed with npx, so it needs the models only for its functions; the // standalone Worker has a package.json of its own. diff --git a/src/starlight/index.ts b/src/starlight/index.ts index e1fb5ef2..f5aaaaf4 100644 --- a/src/starlight/index.ts +++ b/src/starlight/index.ts @@ -22,7 +22,7 @@ import { type OndocsOptions, } from '../astro/integration'; -/** What the dialog shows; passed to `mountAskDialog` from `ondocs/embed`. */ +/** What the dialog shows; passed to `mountAskDialog` from `ondocs/embed/standalone`. */ export interface OndocsDialogOptions extends AstroDialogOptions { /** * An "Ask AI" button in the header, after the search box: `true`, or the button's label, by diff --git a/src/vitepress/index.ts b/src/vitepress/index.ts index 31148486..ed632042 100644 --- a/src/vitepress/index.ts +++ b/src/vitepress/index.ts @@ -44,7 +44,7 @@ import { /** What the dialog shows in one locale, over the dialog's own options. */ export type OndocsLocaleDialogOptions = LocaleDialogOptions; -/** What the dialog shows; passed to `mountAskDialog` from `ondocs/embed`. */ +/** What the dialog shows; passed to `mountAskDialog` from `ondocs/embed/standalone`. */ export interface OndocsDialogOptions extends DialogOptions { /** The dialog's accessible name. Default "Ask {site title}", in each locale its own. */ title?: string; diff --git a/test/__snapshots__/init.test.ts.snap b/test/__snapshots__/init.test.ts.snap index 202d1eb8..06f59eda 100644 --- a/test/__snapshots__/init.test.ts.snap +++ b/test/__snapshots__/init.test.ts.snap @@ -1082,7 +1082,7 @@ Set (init reads none of these): OPENAI_API_KEY, for the site’s build: a build variable; without it there, the index is keyword-only, which works too Next: - 1. npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk + 1. npm i ondocs ai @ai-sdk/openai 2. Add the plugin to Starlight in astro.config: plugins: [ondocs({ mcp: '/api/mcp' })], from 'ondocs/starlight' (mcp points agents that read llms.txt at the MCP endpoint) 3. Deploy as you do now. init has deployed nothing." `; @@ -1174,7 +1174,7 @@ Set (init reads none of these): OPENAI_API_KEY, for the functions and the site’s build: in the project's Settings, Environment Variables; without it, the MCP endpoint works and off-topic questions are refused, but the Ask box cannot answer; for no key at all, init --host github-pages writes a Worker that answers with Workers AI Next: - 1. npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk + 1. npm i ondocs ai @ai-sdk/openai 2. npm i -D @types/node (the functions' types) 3. Add the plugin to Starlight in astro.config: plugins: [ondocs({ mcp: '/api/mcp' })], from 'ondocs/starlight' (mcp points agents that read llms.txt at the MCP endpoint) 4. Deploy as you do now. init has deployed nothing." @@ -1272,7 +1272,7 @@ Set (init reads none of these): OPENAI_API_KEY, for the functions and the site’s build: in the project's Settings, Environment Variables; without it, the MCP endpoint works and off-topic questions are refused, but the Ask box cannot answer; for no key at all, init --host github-pages writes a Worker that answers with Workers AI Next: - 1. npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk + 1. npm i ondocs ai @ai-sdk/openai 2. npm i -D @types/node (the functions' types) 3. Wrap the config in docs/.vitepress/config.mts in withOndocs, from 'ondocs/vitepress': export default withOndocs(defineConfig({ ... }), { mcp: '/api/mcp' }) (mcp points agents that read llms.txt at the MCP endpoint) 4. Deploy as you do now. init has deployed nothing." @@ -1479,7 +1479,7 @@ Set (init reads none of these): CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, for the site’s build, optional: repository secrets, to embed the pages with @cf/baai/bge-small-en-v1.5 for semantic search (a token allowed to run Workers AI); without them, nothing is embedded and questions are matched on keywords Next: - 1. npm i ondocs ai react react-dom @radix-ui/react-dialog cmdk + 1. npm i ondocs ai 2. cd ondocs-worker && npm install && npx wrangler deploy: it prints the Worker's URL, https://acme-ask..workers.dev 3. Wrap the config in .vitepress/config.mts in withOndocs, from 'ondocs/vitepress': export default withOndocs(defineConfig({ ... }), { endpoint: 'https://acme-ask..workers.dev/api/ask', mcp: 'https://acme-ask..workers.dev/api/mcp', embedding: 'workers-ai:@cf/baai/bge-small-en-v1.5' }) (mcp points agents that read llms.txt at the MCP endpoint; the embedding needs CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN as the site builds; leave it out for a keyword-only index) 4. Deploy as you do now. init has deployed nothing." diff --git a/test/init.test.ts b/test/init.test.ts index f9412457..767c458b 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -447,6 +447,8 @@ describe('ondocs init', () => { const result = await run([]); expect(result.code).toBe(0); expect(result.stdout).toContain('Astro with @astrojs/node: nothing to write.'); + // The integration's dialog brings its own React, Radix and cmdk: the site installs none. + expect(result.stdout.split('\n')).toContain('Install: npm i ondocs ai @ai-sdk/openai'); expect(await files(cwd)).toEqual(before); // Without `site`, llms.txt has no URL to name the MCP route by: init says what to set. const hint = diff --git a/test/plugin-packages.test.ts b/test/plugin-packages.test.ts index 582f5205..c65c11ff 100644 --- a/test/plugin-packages.test.ts +++ b/test/plugin-packages.test.ts @@ -41,6 +41,16 @@ const PACKAGES = [ guide: 'docusaurus', keywords: ['docusaurus', 'docusaurus-plugin', 'ask-ai'], noise: ['chatbot', 'ui', 'utility', 'edge', 'command-palette'], + // The theme renders the dialog with the site's React. + peers: [ + '@docusaurus/core', + 'react', + 'react-dom', + '@radix-ui/react-dialog', + 'cmdk', + 'ai', + '@ai-sdk/openai', + ], // Docusaurus loads the plugin's theme and stylesheets by their paths inside ondocs, so a // project that only has this package builds; the endpoint and the CLI still need ondocs. wraps: false, @@ -55,13 +65,16 @@ const PACKAGES = [ // ondocs dropped ui and utility from its own keywords as noise on npm; this package keeps // them for the Astro library's categories, so that ondocs is not listed there twice. noise: ['chatbot', 'edge', 'command-palette'], + // The launcher mounts ondocs/embed/standalone, whose dialog brings its own React, Radix and + // cmdk: npm installs required peers, so asking for them would install React on every site. + peers: ['@astrojs/starlight', 'astro', 'ai', '@ai-sdk/openai'], // The Astro integration adds `ondocs/...` modules to the pages, which the build resolves from // the project: the plugin checks it can, before Starlight sets up. wraps: true, }, ]; -describe.each(PACKAGES)('$dir', ({ dir, entry, guide, keywords, noise, wraps }) => { +describe.each(PACKAGES)('$dir', ({ dir, entry, guide, keywords, noise, peers, wraps }) => { const pkg = manifest(`${dir}/package.json`); it('re-exports its entry of ondocs, default and named, in JavaScript and types', () => { @@ -135,9 +148,7 @@ describe.each(PACKAGES)('$dir', ({ dir, entry, guide, keywords, noise, wraps }) for (const [name, range] of Object.entries(pkg.peerDependencies)) { expect([name, range]).toEqual([name, main.peerDependencies[name]]); } - expect(Object.keys(pkg.peerDependencies)).toEqual( - expect.arrayContaining(['react', 'react-dom', '@radix-ui/react-dialog', 'cmdk', 'ai']), - ); + expect(Object.keys(pkg.peerDependencies).sort()).toEqual([...peers].sort()); }); it('has the keywords people search for, and none that only add noise to npm search', () => { diff --git a/test/standalone-dialog.test.ts b/test/standalone-dialog.test.ts index 599962aa..5e786792 100644 --- a/test/standalone-dialog.test.ts +++ b/test/standalone-dialog.test.ts @@ -29,6 +29,7 @@ describe('the setup guides for sites without React', () => { ['README.md', '**Starlight.** `npm i '], ['examples/nextjs/content/docs/astro.md', 'npm i '], ['examples/nextjs/content/docs/vitepress.md', 'npm i '], + ['packages/starlight-ondocs/README.md', 'npm i '], ].map(([file = '', start = '']) => { const line = read(file) .split('\n') @@ -44,6 +45,20 @@ describe('the setup guides for sites without React', () => { ['README.md', []], ['examples/nextjs/content/docs/astro.md', []], ['examples/nextjs/content/docs/vitepress.md', []], + ['packages/starlight-ondocs/README.md', []], + ]); + }); + + it('match the landing page’s install step for Astro, Starlight and VitePress', () => { + const page = read('examples/nextjs/app/(home)/page.tsx'); + // The last tab with each id is the install section's; the first is the demos'. + const install = (id: string) => + /'npm i ([^']+)'/.exec( + page.slice(page.lastIndexOf(`id: '${id}'`)).split(/\bid: '/)[1] ?? '', + )?.[1]; + expect([install('astro'), install('vitepress')]).toEqual([ + 'ondocs ai @ai-sdk/openai', + 'ondocs ai @ai-sdk/openai', ]); }); }); From 532b05307fe7422ea2ebe8def7047a92793116b0 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 09:40:32 +0100 Subject: [PATCH 4/4] test(js-weight): count what a page loads once idle as loaded before interaction Starlight imports its search UI, about 25 KB gzipped, in a requestIdleCallback. On a busy CI runner that callback ran after the network had gone quiet and the 500 ms wait had passed, so the script charged it to the dialog's opening: Starlight measured 10.3 KB before interaction and +118.4 KB on open, over the 97 KB budget, against 36.1 KB and +92.6 KB on every other run. The script now waits for an idle callback queued after the page's own, then until every script requested has loaded and none has been requested for 500 ms. With the page's idle callbacks delayed by 1.5 s, the old script measured the Starlight example at 10.4 KB before interaction, and this one 36.2 KB. The figures of the unmodified builds are unchanged. --- scripts/js-weight.mjs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/js-weight.mjs b/scripts/js-weight.mjs index b745a0fe..431252eb 100644 --- a/scripts/js-weight.mjs +++ b/scripts/js-weight.mjs @@ -199,8 +199,36 @@ async function measure(name, dir, baseline) { // A response without a body (a redirect). } }); + // Scripts requested and not yet loaded, and every script request so far. + let pending = 0; + let requested = 0; + const isScript = (request) => request.resourceType() === 'script'; + page.on('request', (request) => { + if (!isScript(request)) return; + pending += 1; + requested += 1; + }); + const settled = (request) => { + if (isScript(request)) pending -= 1; + }; + page.on('requestfinished', settled); + page.on('requestfailed', settled); await page.goto(`${server.url}${site.page}`, { waitUntil: 'networkidle' }); - await page.waitForTimeout(500); + // What a page loads on its own once the browser is idle counts too: Starlight imports its + // search UI, about 25 KB gzipped, in a requestIdleCallback, which a busy CI runner can run + // after the network has gone quiet, and the dialog's opening was then charged for it. Wait + // for an idle callback queued after the page's own, then until every script requested has + // loaded and none has been requested for 500 ms. + await page.evaluate( + () => + new Promise((resolve) => { + window.requestIdleCallback(() => resolve(undefined), { timeout: 10_000 }); + }), + ); + for (let seen = -1; seen !== requested || pending > 0;) { + seen = requested; + await page.waitForTimeout(500); + } const inline = await page.evaluate(() => [...document.querySelectorAll('script:not([src])')] .filter((script) => !script.type || /javascript|module/.test(script.type))