mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-01 19:27:48 +02:00
feat(core,theme): useRouteContext + HtmlClassNameProvider (#6933)
Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
This commit is contained in:
parent
9b4ba78f45
commit
8a1421a938
14 changed files with 235 additions and 25 deletions
|
@ -246,6 +246,12 @@ declare module '@docusaurus/useDocusaurusContext' {
|
||||||
export default function useDocusaurusContext(): DocusaurusContext;
|
export default function useDocusaurusContext(): DocusaurusContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '@docusaurus/useRouteContext' {
|
||||||
|
import type {PluginRouteContext} from '@docusaurus/types';
|
||||||
|
|
||||||
|
export default function useRouteContext(): PluginRouteContext;
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@docusaurus/useIsBrowser' {
|
declare module '@docusaurus/useIsBrowser' {
|
||||||
export default function useIsBrowser(): boolean;
|
export default function useIsBrowser(): boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -191,6 +191,7 @@ declare module '@theme/DocItem' {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Metadata = {
|
export type Metadata = {
|
||||||
|
readonly unversionedId?: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly title?: string;
|
readonly title?: string;
|
||||||
readonly permalink?: string;
|
readonly permalink?: string;
|
||||||
|
|
|
@ -17,7 +17,11 @@ import TOC from '@theme/TOC';
|
||||||
import TOCCollapsible from '@theme/TOCCollapsible';
|
import TOCCollapsible from '@theme/TOCCollapsible';
|
||||||
import Heading from '@theme/Heading';
|
import Heading from '@theme/Heading';
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
import {ThemeClassNames, useWindowSize} from '@docusaurus/theme-common';
|
import {
|
||||||
|
HtmlClassNameProvider,
|
||||||
|
ThemeClassNames,
|
||||||
|
useWindowSize,
|
||||||
|
} from '@docusaurus/theme-common';
|
||||||
import DocBreadcrumbs from '@theme/DocBreadcrumbs';
|
import DocBreadcrumbs from '@theme/DocBreadcrumbs';
|
||||||
import MDXContent from '@theme/MDXContent';
|
import MDXContent from '@theme/MDXContent';
|
||||||
|
|
||||||
|
@ -49,7 +53,7 @@ export default function DocItem(props: Props): JSX.Element {
|
||||||
canRenderTOC && (windowSize === 'desktop' || windowSize === 'ssr');
|
canRenderTOC && (windowSize === 'desktop' || windowSize === 'ssr');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HtmlClassNameProvider className={`docs-doc-id-${metadata.unversionedId}`}>
|
||||||
<Seo {...{title, description, keywords, image}} />
|
<Seo {...{title, description, keywords, image}} />
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
@ -107,6 +111,6 @@ export default function DocItem(props: Props): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</HtmlClassNameProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,14 +20,15 @@ import {translate} from '@docusaurus/Translate';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
HtmlClassNameProvider,
|
||||||
ThemeClassNames,
|
ThemeClassNames,
|
||||||
docVersionSearchTag,
|
docVersionSearchTag,
|
||||||
DocsSidebarProvider,
|
DocsSidebarProvider,
|
||||||
useDocsSidebar,
|
useDocsSidebar,
|
||||||
DocsVersionProvider,
|
DocsVersionProvider,
|
||||||
} from '@docusaurus/theme-common';
|
} from '@docusaurus/theme-common';
|
||||||
import Head from '@docusaurus/Head';
|
|
||||||
|
|
||||||
type DocPageContentProps = {
|
type DocPageContentProps = {
|
||||||
readonly currentDocRoute: DocumentRoute;
|
readonly currentDocRoute: DocumentRoute;
|
||||||
|
@ -160,11 +161,7 @@ export default function DocPage(props: Props): JSX.Element {
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HtmlClassNameProvider className={versionMetadata.className}>
|
||||||
<Head>
|
|
||||||
{/* TODO we should add a core addRoute({htmlClassName}) action */}
|
|
||||||
<html className={versionMetadata.className} />
|
|
||||||
</Head>
|
|
||||||
<DocsVersionProvider version={versionMetadata}>
|
<DocsVersionProvider version={versionMetadata}>
|
||||||
<DocsSidebarProvider sidebar={sidebar ?? null}>
|
<DocsSidebarProvider sidebar={sidebar ?? null}>
|
||||||
<DocPageContent
|
<DocPageContent
|
||||||
|
@ -175,6 +172,6 @@ export default function DocPage(props: Props): JSX.Element {
|
||||||
</DocPageContent>
|
</DocPageContent>
|
||||||
</DocsSidebarProvider>
|
</DocsSidebarProvider>
|
||||||
</DocsVersionProvider>
|
</DocsVersionProvider>
|
||||||
</>
|
</HtmlClassNameProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
DocsPreferredVersionContextProvider,
|
DocsPreferredVersionContextProvider,
|
||||||
MobileSecondaryMenuProvider,
|
MobileSecondaryMenuProvider,
|
||||||
ScrollControllerProvider,
|
ScrollControllerProvider,
|
||||||
|
PluginHtmlClassNameProvider,
|
||||||
} from '@docusaurus/theme-common';
|
} from '@docusaurus/theme-common';
|
||||||
import type {Props} from '@theme/LayoutProviders';
|
import type {Props} from '@theme/LayoutProviders';
|
||||||
|
|
||||||
|
@ -24,7 +25,9 @@ export default function LayoutProviders({children}: Props): JSX.Element {
|
||||||
<ScrollControllerProvider>
|
<ScrollControllerProvider>
|
||||||
<DocsPreferredVersionContextProvider>
|
<DocsPreferredVersionContextProvider>
|
||||||
<MobileSecondaryMenuProvider>
|
<MobileSecondaryMenuProvider>
|
||||||
{children}
|
<PluginHtmlClassNameProvider>
|
||||||
|
{children}
|
||||||
|
</PluginHtmlClassNameProvider>
|
||||||
</MobileSecondaryMenuProvider>
|
</MobileSecondaryMenuProvider>
|
||||||
</DocsPreferredVersionContextProvider>
|
</DocsPreferredVersionContextProvider>
|
||||||
</ScrollControllerProvider>
|
</ScrollControllerProvider>
|
||||||
|
|
|
@ -135,6 +135,11 @@ export {isRegexpStringMatch} from './utils/regexpUtils';
|
||||||
|
|
||||||
export {useHomePageRoute} from './utils/routesUtils';
|
export {useHomePageRoute} from './utils/routesUtils';
|
||||||
|
|
||||||
|
export {
|
||||||
|
HtmlClassNameProvider,
|
||||||
|
PluginHtmlClassNameProvider,
|
||||||
|
} from './utils/metadataUtilsTemp';
|
||||||
|
|
||||||
export {useColorMode, ColorModeProvider} from './utils/colorModeUtils';
|
export {useColorMode, ColorModeProvider} from './utils/colorModeUtils';
|
||||||
export {
|
export {
|
||||||
useTabGroupChoice,
|
useTabGroupChoice,
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
/**
|
||||||
|
* 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 React, {type ReactNode} from 'react';
|
||||||
|
import Head from '@docusaurus/Head';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import useRouteContext from '@docusaurus/useRouteContext';
|
||||||
|
|
||||||
|
const HtmlClassNameContext = React.createContext<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// This wrapper is necessary because Helmet does not "merge" classes
|
||||||
|
// See https://github.com/staylor/react-helmet-async/issues/161
|
||||||
|
export function HtmlClassNameProvider({
|
||||||
|
className: classNameProp,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
className: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
const classNameContext = React.useContext(HtmlClassNameContext);
|
||||||
|
const className = clsx(classNameContext, classNameProp);
|
||||||
|
return (
|
||||||
|
<HtmlClassNameContext.Provider value={className}>
|
||||||
|
<Head>
|
||||||
|
<html className={className} />
|
||||||
|
</Head>
|
||||||
|
{children}
|
||||||
|
</HtmlClassNameContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginNameToClassName(pluginName: string) {
|
||||||
|
return `plugin-${pluginName.replace(
|
||||||
|
/docusaurus-(?:plugin|theme)-(?:content-)?/gi,
|
||||||
|
'',
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginHtmlClassNameProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
const routeContext = useRouteContext();
|
||||||
|
const nameClass = pluginNameToClassName(routeContext.plugin.name);
|
||||||
|
const idClass = `plugin-id-${routeContext.plugin.id}`;
|
||||||
|
return (
|
||||||
|
<HtmlClassNameProvider className={clsx(nameClass, idClass)}>
|
||||||
|
{children}
|
||||||
|
</HtmlClassNameProvider>
|
||||||
|
);
|
||||||
|
}
|
18
packages/docusaurus-types/src/index.d.ts
vendored
18
packages/docusaurus-types/src/index.d.ts
vendored
|
@ -377,6 +377,24 @@ export interface RouteConfig {
|
||||||
[propName: string]: unknown;
|
[propName: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RouteContext {
|
||||||
|
/**
|
||||||
|
* Plugin-specific context data.
|
||||||
|
*/
|
||||||
|
data?: object | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level plugin routes automatically add some context data to the route.
|
||||||
|
* This permits us to know which plugin is handling the current route.
|
||||||
|
*/
|
||||||
|
export interface PluginRouteContext extends RouteContext {
|
||||||
|
plugin: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type Route = {
|
export type Route = {
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly component: ReturnType<typeof Loadable>;
|
readonly component: ReturnType<typeof Loadable>;
|
||||||
|
|
|
@ -11,6 +11,7 @@ import Loading from '@theme/Loading';
|
||||||
import routesChunkNames from '@generated/routesChunkNames';
|
import routesChunkNames from '@generated/routesChunkNames';
|
||||||
import registry from '@generated/registry';
|
import registry from '@generated/registry';
|
||||||
import flat from '../flat';
|
import flat from '../flat';
|
||||||
|
import {RouteContextProvider} from '../routeContext';
|
||||||
|
|
||||||
type OptsLoader = Record<string, typeof registry[keyof typeof registry][0]>;
|
type OptsLoader = Record<string, typeof registry[keyof typeof registry][0]>;
|
||||||
|
|
||||||
|
@ -22,7 +23,16 @@ export default function ComponentCreator(
|
||||||
if (path === '*') {
|
if (path === '*') {
|
||||||
return Loadable({
|
return Loadable({
|
||||||
loading: Loading,
|
loading: Loading,
|
||||||
loader: () => import('@theme/NotFound'),
|
loader: async () => {
|
||||||
|
const NotFound = (await import('@theme/NotFound')).default;
|
||||||
|
return (props) => (
|
||||||
|
// Is there a better API for this?
|
||||||
|
<RouteContextProvider
|
||||||
|
value={{plugin: {name: 'native', id: 'default'}}}>
|
||||||
|
<NotFound {...(props as never)} />
|
||||||
|
</RouteContextProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,7 +94,18 @@ export default function ComponentCreator(
|
||||||
|
|
||||||
const Component = loadedModules.component;
|
const Component = loadedModules.component;
|
||||||
delete loadedModules.component;
|
delete loadedModules.component;
|
||||||
return <Component {...loadedModules} {...props} />;
|
|
||||||
|
/* eslint-disable no-underscore-dangle */
|
||||||
|
const routeContextModule = loadedModules.__routeContextModule;
|
||||||
|
delete loadedModules.__routeContextModule;
|
||||||
|
/* eslint-enable no-underscore-dangle */
|
||||||
|
|
||||||
|
// Is there any way to put this RouteContextProvider upper in the tree?
|
||||||
|
return (
|
||||||
|
<RouteContextProvider value={routeContextModule}>
|
||||||
|
<Component {...loadedModules} {...props} />;
|
||||||
|
</RouteContextProvider>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
20
packages/docusaurus/src/client/exports/useRouteContext.tsx
Normal file
20
packages/docusaurus/src/client/exports/useRouteContext.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* 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 React from 'react';
|
||||||
|
import type {PluginRouteContext} from '@docusaurus/types';
|
||||||
|
import {Context} from '../routeContext';
|
||||||
|
|
||||||
|
export default function useRouteContext(): PluginRouteContext {
|
||||||
|
const context = React.useContext(Context);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'Unexpected: no Docusaurus parent/current route context found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
58
packages/docusaurus/src/client/routeContext.tsx
Normal file
58
packages/docusaurus/src/client/routeContext.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* 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 React, {useMemo, type ReactNode} from 'react';
|
||||||
|
import type {PluginRouteContext, RouteContext} from '@docusaurus/types';
|
||||||
|
|
||||||
|
export const Context = React.createContext<PluginRouteContext | null>(null);
|
||||||
|
|
||||||
|
function mergeContexts({
|
||||||
|
parent,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
parent: PluginRouteContext | null;
|
||||||
|
value: RouteContext | null;
|
||||||
|
}): PluginRouteContext {
|
||||||
|
if (!parent) {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(
|
||||||
|
'Unexpected: no Docusaurus parent/current route context found',
|
||||||
|
);
|
||||||
|
} else if (!('plugin' in value)) {
|
||||||
|
throw new Error(
|
||||||
|
'Unexpected: Docusaurus parent route context has no plugin attribute',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO deep merge this
|
||||||
|
const data = {...parent.data, ...value?.data};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// nested routes are not supposed to override plugin attribute
|
||||||
|
plugin: parent.plugin,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteContextProvider({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
value: PluginRouteContext | null;
|
||||||
|
}): JSX.Element {
|
||||||
|
const parent = React.useContext(Context);
|
||||||
|
|
||||||
|
const mergedValue = useMemo(
|
||||||
|
() => mergeContexts({parent, value}),
|
||||||
|
[parent, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Context.Provider value={mergedValue}>{children}</Context.Provider>;
|
||||||
|
}
|
|
@ -5,7 +5,7 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {generate} from '@docusaurus/utils';
|
import {docuHash, generate} from '@docusaurus/utils';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type {
|
import type {
|
||||||
|
@ -18,6 +18,7 @@ import type {
|
||||||
ThemeConfig,
|
ThemeConfig,
|
||||||
LoadedPlugin,
|
LoadedPlugin,
|
||||||
InitializedPlugin,
|
InitializedPlugin,
|
||||||
|
PluginRouteContext,
|
||||||
} from '@docusaurus/types';
|
} from '@docusaurus/types';
|
||||||
import initPlugins from './init';
|
import initPlugins from './init';
|
||||||
import logger from '@docusaurus/logger';
|
import logger from '@docusaurus/logger';
|
||||||
|
@ -149,17 +150,6 @@ export async function loadPlugins({
|
||||||
const dataDirRoot = path.join(context.generatedFilesDir, plugin.name);
|
const dataDirRoot = path.join(context.generatedFilesDir, plugin.name);
|
||||||
const dataDir = path.join(dataDirRoot, pluginId);
|
const dataDir = path.join(dataDirRoot, pluginId);
|
||||||
|
|
||||||
const addRoute: PluginContentLoadedActions['addRoute'] = (
|
|
||||||
initialRouteConfig,
|
|
||||||
) => {
|
|
||||||
// Trailing slash behavior is handled in a generic way for all plugins
|
|
||||||
const finalRouteConfig = applyRouteTrailingSlash(initialRouteConfig, {
|
|
||||||
trailingSlash: context.siteConfig.trailingSlash,
|
|
||||||
baseUrl: context.siteConfig.baseUrl,
|
|
||||||
});
|
|
||||||
pluginsRouteConfigs.push(finalRouteConfig);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createData: PluginContentLoadedActions['createData'] = async (
|
const createData: PluginContentLoadedActions['createData'] = async (
|
||||||
name,
|
name,
|
||||||
data,
|
data,
|
||||||
|
@ -170,6 +160,34 @@ export async function loadPlugins({
|
||||||
return modulePath;
|
return modulePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO this would be better to do all that in the codegen phase
|
||||||
|
// TODO handle context for nested routes
|
||||||
|
const pluginRouteContext: PluginRouteContext = {
|
||||||
|
plugin: {name: plugin.name, id: pluginId},
|
||||||
|
data: undefined, // TODO allow plugins to provide context data
|
||||||
|
};
|
||||||
|
const pluginRouteContextModulePath = await createData(
|
||||||
|
`${docuHash('pluginRouteContextModule')}.json`,
|
||||||
|
JSON.stringify(pluginRouteContext, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addRoute: PluginContentLoadedActions['addRoute'] = (
|
||||||
|
initialRouteConfig,
|
||||||
|
) => {
|
||||||
|
// Trailing slash behavior is handled in a generic way for all plugins
|
||||||
|
const finalRouteConfig = applyRouteTrailingSlash(initialRouteConfig, {
|
||||||
|
trailingSlash: context.siteConfig.trailingSlash,
|
||||||
|
baseUrl: context.siteConfig.baseUrl,
|
||||||
|
});
|
||||||
|
pluginsRouteConfigs.push({
|
||||||
|
...finalRouteConfig,
|
||||||
|
modules: {
|
||||||
|
...finalRouteConfig.modules,
|
||||||
|
__routeContextModule: pluginRouteContextModulePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// the plugins global data are namespaced to avoid data conflicts:
|
// the plugins global data are namespaced to avoid data conflicts:
|
||||||
// - by plugin name
|
// - by plugin name
|
||||||
// - by plugin id (allow using multiple instances of the same plugin)
|
// - by plugin id (allow using multiple instances of the same plugin)
|
||||||
|
|
|
@ -21,6 +21,7 @@ exports[`base webpack config creates webpack aliases 1`] = `
|
||||||
"@docusaurus/useDocusaurusContext": "../../../../client/exports/useDocusaurusContext.ts",
|
"@docusaurus/useDocusaurusContext": "../../../../client/exports/useDocusaurusContext.ts",
|
||||||
"@docusaurus/useGlobalData": "../../../../client/exports/useGlobalData.ts",
|
"@docusaurus/useGlobalData": "../../../../client/exports/useGlobalData.ts",
|
||||||
"@docusaurus/useIsBrowser": "../../../../client/exports/useIsBrowser.ts",
|
"@docusaurus/useIsBrowser": "../../../../client/exports/useIsBrowser.ts",
|
||||||
|
"@docusaurus/useRouteContext": "../../../../client/exports/useRouteContext.tsx",
|
||||||
"@generated": "../../../../../../..",
|
"@generated": "../../../../../../..",
|
||||||
"@site": "",
|
"@site": "",
|
||||||
"@theme-init/PluginThemeComponentEnhanced": "pluginThemeFolder/PluginThemeComponentEnhanced.js",
|
"@theme-init/PluginThemeComponentEnhanced": "pluginThemeFolder/PluginThemeComponentEnhanced.js",
|
||||||
|
@ -68,5 +69,6 @@ exports[`getDocusaurusAliases() returns appropriate webpack aliases 1`] = `
|
||||||
"@docusaurus/useDocusaurusContext": "../../client/exports/useDocusaurusContext.ts",
|
"@docusaurus/useDocusaurusContext": "../../client/exports/useDocusaurusContext.ts",
|
||||||
"@docusaurus/useGlobalData": "../../client/exports/useGlobalData.ts",
|
"@docusaurus/useGlobalData": "../../client/exports/useGlobalData.ts",
|
||||||
"@docusaurus/useIsBrowser": "../../client/exports/useIsBrowser.ts",
|
"@docusaurus/useIsBrowser": "../../client/exports/useIsBrowser.ts",
|
||||||
|
"@docusaurus/useRouteContext": "../../client/exports/useRouteContext.tsx",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -40,6 +40,7 @@ chedeau
|
||||||
cheng
|
cheng
|
||||||
clément
|
clément
|
||||||
clsx
|
clsx
|
||||||
|
codegen
|
||||||
codeql
|
codeql
|
||||||
codesandbox
|
codesandbox
|
||||||
codespaces
|
codespaces
|
||||||
|
|
Loading…
Add table
Reference in a new issue