diff --git a/packages/docusaurus-plugin-vercel-analytics/.npmignore b/packages/docusaurus-plugin-vercel-analytics/.npmignore new file mode 100644 index 000000000000..03c9ae1e1b54 --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/.npmignore @@ -0,0 +1,3 @@ +.tsbuildinfo* +tsconfig* +__tests__ diff --git a/packages/docusaurus-plugin-vercel-analytics/README.md b/packages/docusaurus-plugin-vercel-analytics/README.md new file mode 100644 index 000000000000..0234cb5fdba5 --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/README.md @@ -0,0 +1,7 @@ +# `@docusaurus/plugin-vercel-analytics` + +[Vercel analytics](https://vercel.com/docs/analytics) plugin for Docusaurus. + +## Usage + +See [plugin-vercel-analytics documentation](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-vercel-analytics). diff --git a/packages/docusaurus-plugin-vercel-analytics/package.json b/packages/docusaurus-plugin-vercel-analytics/package.json new file mode 100644 index 000000000000..e4b8ef4f780b --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/package.json @@ -0,0 +1,36 @@ +{ + "name": "@docusaurus/plugin-vercel-analytics", + "version": "3.0.0", + "description": "Global vercel analytics plugin for Docusaurus.", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc --build", + "watch": "tsc --build --watch" + }, + "repository": { + "type": "git", + "url": "https://github.com/facebook/docusaurus.git", + "directory": "packages/docusaurus-plugin-vercel-analytics" + }, + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.0.0", + "@docusaurus/logger": "3.0.0", + "@docusaurus/types": "3.0.0", + "@docusaurus/utils-validation": "3.0.0", + "@docusaurus/utils": "3.0.0", + "@vercel/analytics": "^1.1.1", + "tslib": "^2.6.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18.0" + } +} diff --git a/packages/docusaurus-plugin-vercel-analytics/src/__tests__/options.test.ts b/packages/docusaurus-plugin-vercel-analytics/src/__tests__/options.test.ts new file mode 100644 index 000000000000..41af7e32d0b7 --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/src/__tests__/options.test.ts @@ -0,0 +1,135 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {normalizePluginOptions} from '@docusaurus/utils-validation'; +import {validateOptions, type PluginOptions, type Options} from '../options'; +import type {Validate} from '@docusaurus/types'; + +function testValidateOptions(options: Options) { + return validateOptions({ + validate: normalizePluginOptions as Validate, + options, + }); +} + +function validationResult(options: Options) { + return { + id: 'default', + ...options, + }; +} + +describe('validateOptions', () => { + it('accepts for undefined options', () => { + // @ts-expect-error: TS should error + expect(testValidateOptions(undefined)).toEqual(validationResult(undefined)); + }); + + it('throws for custom id', () => { + const config: Options = {id: 'custom', mode: 'auto', debug: false}; + expect(() => testValidateOptions(config)) + .toThrowErrorMatchingInlineSnapshot(` + "You site uses the Vercel Analytics plugin with a custom plugin id (custom). + But this plugin is only supposed to be used at most once per site. Therefore providing a custom plugin id is unsupported." + `); + }); + + it('accept for default id', () => { + const config: Options = {id: 'default', mode: 'auto', debug: false}; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('throws for null options', () => { + // @ts-expect-error: TS should error + expect(() => testValidateOptions(null)).toThrowErrorMatchingInlineSnapshot( + `""value" must be of type object"`, + ); + }); + + it('accept for empty object options', () => { + const config: Options = {}; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('throws for number options', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions(42), + ).toThrowErrorMatchingInlineSnapshot(`""value" must be of type object"`); + }); + + it('throws for null mode', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions({mode: null}), + ).toThrowErrorMatchingInlineSnapshot( + `""mode" must be one of [auto, production, development]"`, + ); + }); + it('throws for number mode', () => { + expect( + // @ts-expect-error: TS should error + () => testValidateOptions({mode: 42}), + ).toThrowErrorMatchingInlineSnapshot( + `""mode" must be one of [auto, production, development]"`, + ); + }); + it('throws for empty mode', () => { + expect(() => + // @ts-expect-error: TS should error + testValidateOptions({mode: ''}), + ).toThrowErrorMatchingInlineSnapshot( + `""mode" must be one of [auto, production, development]"`, + ); + }); + + it('accepts debug true', () => { + const config: Options = { + debug: true, + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('accepts debug false', () => { + const config: Options = { + debug: false, + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('accepts mode prod', () => { + const config: Options = { + mode: 'production', + debug: false, + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('accepts mode dev', () => { + const config: Options = { + mode: 'development', + debug: false, + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('accepts mode prod with debug', () => { + const config: Options = { + mode: 'production', + debug: true, + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); + + it('accepts mode dev with debug', () => { + const config: Options = { + mode: 'development', + debug: true, + }; + expect(testValidateOptions(config)).toEqual(validationResult(config)); + }); +}); diff --git a/packages/docusaurus-plugin-vercel-analytics/src/analytics.ts b/packages/docusaurus-plugin-vercel-analytics/src/analytics.ts new file mode 100644 index 000000000000..0af51ce9269a --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/src/analytics.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import {inject} from '@vercel/analytics'; +import globalData from '@generated/globalData'; +import type {PluginOptions} from './options'; + +const {debug, mode} = globalData['docusaurus-plugin-vercel-analytics'] + ?.default as PluginOptions; + +inject({ + mode, + debug, +}); diff --git a/packages/docusaurus-plugin-vercel-analytics/src/index.ts b/packages/docusaurus-plugin-vercel-analytics/src/index.ts new file mode 100644 index 000000000000..c05d1cefe83a --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/src/index.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {LoadContext, Plugin} from '@docusaurus/types'; +import type {PluginOptions, Options} from './options'; + +export default function pluginVercelAnalytics( + context: LoadContext, + options: PluginOptions, +): Plugin { + const isProd = process.env.NODE_ENV === 'production'; + + return { + name: 'docusaurus-plugin-vercel-analytics', + + getClientModules() { + return isProd ? ['./analytics'] : []; + }, + + contentLoaded({actions}) { + actions.setGlobalData(options); + }, + }; +} + +export {validateOptions} from './options'; + +export type {PluginOptions, Options}; diff --git a/packages/docusaurus-plugin-vercel-analytics/src/options.ts b/packages/docusaurus-plugin-vercel-analytics/src/options.ts new file mode 100644 index 000000000000..754866ab0c2d --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/src/options.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils'; +import logger from '@docusaurus/logger'; +import {Joi} from '@docusaurus/utils-validation'; +import type {OptionValidationContext} from '@docusaurus/types'; + +export type PluginOptions = { + id: string; + mode: 'auto' | 'production' | 'development' | undefined; + debug: boolean | undefined; +}; + +export type Options = Partial; + +const pluginOptionsSchema = Joi.object({ + mode: Joi.string().valid('auto', 'production', 'development').optional(), + debug: Joi.boolean().optional(), +}); + +// We can't validate this through the schema +// Docusaurus core auto registers the id field to the schema already +function ensureNoMultiInstance(options: Options) { + if (options?.id && options.id !== DEFAULT_PLUGIN_ID) { + throw new Error( + logger.interpolate`You site uses the Vercel Analytics plugin with a custom plugin id (name=${options.id}). + But this plugin is only supposed to be used at most once per site. Therefore providing a custom plugin id is unsupported.`, + ); + } +} + +export function validateOptions({ + validate, + options, +}: OptionValidationContext): PluginOptions { + ensureNoMultiInstance(options); + return validate(pluginOptionsSchema, options); +} diff --git a/packages/docusaurus-plugin-vercel-analytics/src/types.d.ts b/packages/docusaurus-plugin-vercel-analytics/src/types.d.ts new file mode 100644 index 000000000000..6f6f99f12793 --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/src/types.d.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/// diff --git a/packages/docusaurus-plugin-vercel-analytics/tsconfig.client.json b/packages/docusaurus-plugin-vercel-analytics/tsconfig.client.json new file mode 100644 index 000000000000..e77a5fd904ff --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/tsconfig.client.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "composite": true, + "incremental": true, + "tsBuildInfoFile": "./lib/.tsbuildinfo-client", + "moduleResolution": "bundler", + "module": "esnext", + "target": "esnext", + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/analytics.ts", "src/options.ts", "src/*.d.ts"], + "exclude": ["**/__tests__/**"] +} diff --git a/packages/docusaurus-plugin-vercel-analytics/tsconfig.json b/packages/docusaurus-plugin-vercel-analytics/tsconfig.json new file mode 100644 index 000000000000..c7fda37effc4 --- /dev/null +++ b/packages/docusaurus-plugin-vercel-analytics/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "references": [{"path": "./tsconfig.client.json"}], + "compilerOptions": { + "noEmit": false, + "incremental": true, + "tsBuildInfoFile": "./lib/.tsbuildinfo", + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "exclude": ["src/analytics.ts", "**/__tests__/**"] +} diff --git a/website/docs/api/plugins/plugin-vercel-analytics.mdx b/website/docs/api/plugins/plugin-vercel-analytics.mdx new file mode 100644 index 000000000000..1285a776f61d --- /dev/null +++ b/website/docs/api/plugins/plugin-vercel-analytics.mdx @@ -0,0 +1,57 @@ +--- +sidebar_position: 11 +slug: /api/plugins/@docusaurus/plugin-vercel-analytics +--- + +# 📦 plugin-vercel-analytics + +import APITable from '@site/src/components/APITable'; + +[Vercel Analytics](https://vercel.com/docs/analytics) provides comprehensive insights into your website's visitors, tracking top pages, referrers, and demographics like location, operating systems, and browser info. + +:::warning production only + +This plugin is always inactive in development and **only active in production** (`docusaurus build`) to avoid polluting the analytics statistics. + +::: + +## Installation {#installation} + +```bash npm2yarn +npm install --save @docusaurus/plugin-vercel-analytics +``` + +## Configuration {#configuration} + +Accepted fields: + +```mdx-code-block + +``` + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `mode` | `string` | `'auto'` | Override the automatic environment detection. Read the [official docs](https://vercel.com/docs/analytics/package#mode) for details. | +| `debug` | `boolean` | `undefined` | Enable browser console logging of analytics events. SRead the [official docs](https://vercel.com/docs/analytics/package#debug) for details. | + +```mdx-code-block + +``` + +### Example configuration {#ex-config} + +You can configure this plugin through plugin options. + +```js title="docusaurus.config.js" +export default { + plugins: [ + [ + 'vercel-analytics', + { + debug: true, + mode: 'auto', + }, + ], + ], +}; +``` diff --git a/website/src/components/APITable/index.tsx b/website/src/components/APITable/index.tsx index 049e2d4bcfd4..0772f163badd 100644 --- a/website/src/components/APITable/index.tsx +++ b/website/src/components/APITable/index.tsx @@ -22,11 +22,20 @@ interface Props { } // ReactNode equivalent of HTMLElement#innerText -function getText(node: ReactElement): string { +function getRowName(node: ReactElement): string { let curNode: ReactNode = node; while (isValidElement(curNode)) { [curNode] = React.Children.toArray(curNode.props.children); } + if (typeof curNode !== 'string') { + throw new Error( + `Could not extract APITable row name from JSX tree:\n${JSON.stringify( + node, + null, + 2, + )}`, + ); + } return curNode as string; } @@ -37,7 +46,7 @@ function APITableRow( }: {name: string | undefined; children: ReactElement>}, ref: React.ForwardedRef, ) { - const entryName = getText(children); + const entryName = getRowName(children); const id = name ? `${name}-${entryName}` : entryName; const anchor = `#${id}`; const history = useHistory(); @@ -71,6 +80,11 @@ const APITableRowComp = React.forwardRef(APITableRow); * should be generally correct in the MDX context. */ export default function APITable({children, name}: Props): JSX.Element { + if (children.type !== 'table') { + throw new Error( + 'Bad usage of APITable component.\nIt is probably that your Markdown table is malformed.\nMake sure to double-check you have the appropriate number of columns for each table row.', + ); + } const [thead, tbody] = React.Children.toArray(children.props.children) as [ ReactElement<{children: ReactElement[]}>, ReactElement<{children: ReactElement[]}>, diff --git a/yarn.lock b/yarn.lock index ab5360ca4a31..0537649d74fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3863,6 +3863,13 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@vercel/analytics@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@vercel/analytics/-/analytics-1.1.1.tgz#2a712378a95014a548b4f9d2ae1ea0721433908d" + integrity sha512-+NqgNmSabg3IFfxYhrWCfB/H+RCUOCR5ExRudNG2+pcRehq628DJB5e1u1xqwpLtn4pAYii4D98w7kofORAGQA== + dependencies: + server-only "^0.0.1" + "@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" @@ -14718,6 +14725,11 @@ serve-static@1.15.0: parseurl "~1.3.3" send "0.18.0" +server-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/server-only/-/server-only-0.0.1.tgz#0f366bb6afb618c37c9255a314535dc412cd1c9e" + integrity sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA== + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"