feat: init @affine/copilot (#2511)

This commit is contained in:
Himself65
2023-05-30 18:02:49 +08:00
parent e7e447d0e1
commit a7ac6562b0
49 changed files with 2963 additions and 1331 deletions

View File

@@ -0,0 +1,25 @@
{
"name": "@toeverything/plugin-infra",
"private": true,
"scripts": {
"build": "vite build"
},
"exports": {
"./manager": "./src/manager.ts",
"./type": "./src/type.ts",
"./react": "./src/react/index.ts"
},
"dependencies": {
"@affine/component": "workspace:*",
"@affine/env": "workspace:*",
"@affine/workspace": "workspace:*"
},
"devDependencies": {
"jotai": "^2.1.0"
},
"peerDependencies": {
"jotai": "*",
"react": "*",
"react-dom": "*"
}
}

View File

@@ -0,0 +1,50 @@
import { DebugLogger } from '@affine/debug';
import { rootStore } from '@affine/workspace/atom';
import { atom } from 'jotai';
import type { AffinePlugin, Definition } from './type';
import type { Loader, PluginUIAdapter } from './type';
// todo: for now every plugin is enabled by default
export const affinePluginsAtom = atom<Record<string, AffinePlugin<string>>>({});
const pluginLogger = new DebugLogger('affine:plugin');
import { config } from '@affine/env';
export function definePlugin<ID extends string>(
definition: Definition<ID>,
uiAdapterLoader?: Loader<Partial<PluginUIAdapter>>
) {
if (!config.enablePlugin) {
return;
}
const basePlugin = {
definition,
uiAdapter: {},
};
rootStore.set(affinePluginsAtom, plugins => ({
...plugins,
[definition.id]: basePlugin,
}));
if (uiAdapterLoader) {
const updateAdapter = (adapter: Partial<PluginUIAdapter>) => {
rootStore.set(affinePluginsAtom, plugins => ({
...plugins,
[definition.id]: {
...basePlugin,
uiAdapter: adapter,
},
}));
};
uiAdapterLoader
.load()
.then(({ default: adapter }) => updateAdapter(adapter));
if (import.meta.webpackHot) {
uiAdapterLoader.hotModuleReload(async _ => {
const adapter = (await _).default;
updateAdapter(adapter);
pluginLogger.info('[HMR] Plugin', definition.id, 'hot reloaded.');
});
}
}
}

View File

@@ -0,0 +1,23 @@
import { ProviderComposer } from '@affine/component/provider-composer';
import { ThemeProvider } from '@affine/component/theme-provider';
import { rootStore } from '@affine/workspace/atom';
import { Provider } from 'jotai';
import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';
export function AffinePluginContext(props: PropsWithChildren) {
return (
<ProviderComposer
contexts={useMemo(
() =>
[
<Provider key="JotaiProvider" store={rootStore} />,
<ThemeProvider key="ThemeProvider" />,
].filter(Boolean),
[]
)}
>
{props.children}
</ProviderComposer>
);
}

View File

@@ -0,0 +1 @@
export * from './context';

View File

@@ -0,0 +1,162 @@
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path="./webpack-hmr.d.ts" />
/**
* AFFiNE Plugin System Types
*/
import type { WritableAtom } from 'jotai';
import type { ReactElement } from 'react';
import type { MosaicDirection, MosaicNode } from 'react-mosaic-component';
/**
* A code loader interface of the plugin API.
*
* Plugin should be lazy-loaded. If a plugin is not enabled, it will not be loaded into the Mask.
*
* @example
* ```ts
* const loader = {
* load: () => import("./code"),
* hotModuleReload: hot => import.meta.webpackHot && import.meta.webpackHot.accept('./code', () => hot(import("./code")))
* }
* ```
*
* The `./code` should use `export default` to export what loader expects.
*/
export interface Loader<DeferredModule> {
/**
* The `load()` function will be called on demand.
*
* It should not have side effects (e.g. start some daemon, start a new HTTP request or WebSocket client),
* those work should be in the `.init()` function.
* @returns the actual definition of this plugin
* @example load: () => import('./path')
*/
load(): Promise<{
default: DeferredModule;
}>;
/**
* This provides the functionality for hot module reload on the plugin.
* When the callback is called, the old instance of the plugin will be unloaded, then the new instance will be init.
* @example hotModuleReload: hot => import.meta.webpackHot && import.meta.webpackHot.accept('./path', () => hot(import('./path')))
*/
hotModuleReload(
onHot: (
hot: Promise<{
default: DeferredModule;
}>
) => void
): void;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-unused-vars
interface AFFiNEPlugin {
// todo: add more fields
}
export interface I18NStringField {
/** The i18n key of the string content. */
i18nKey?: string;
/** The fallback content to display if there is no i18n string found. */
fallback: string;
}
/** The publisher of the plugin */
export interface Publisher {
/** The name of the publisher */
name: I18NStringField;
/** URL of the publisher */
link: string;
}
/** For what stage the plugin */
export enum ReleaseStage {
NIGHTLY = 'nightly',
PROD = 'prod',
DEV = 'dev',
}
export type ExpectedLayout =
| {
direction: MosaicDirection;
// the first element is always the editor
first: 'editor';
second: MosaicNode<string>;
// the percentage should be greater than 70
splitPercentage?: number;
}
| 'editor';
type SetStateAction<Value> = Value | ((prev: Value) => Value);
export type ContentLayoutAtom = WritableAtom<
ExpectedLayout,
[SetStateAction<ExpectedLayout>],
void
>;
export type Definition<ID extends string> = {
/**
* ID of the plugin. It should be unique.
* @example "com.affine.pro"
*/
id: ID;
/**
* The human-readable name of the plugin.
* @example { i18nKey: "name", fallback: "Never gonna give you up" }
*/
name: I18NStringField;
/**
* A brief description of this plugin.
* @example { i18nKey: "description", fallback: "This plugin is going to replace every link in the page to https://www.youtube.com/watch?v=dQw4w9WgXcQ" }
*/
description?: I18NStringField;
/**
* Publisher of this plugin.
* @example { link: "https://affine.pro", name: { fallback: "AFFiNE", i18nKey: "org_name" } }
*/
publisher?: Publisher;
/**
* The version of this plugin.
* @example "1.0.0"
*/
version: string;
/**
* The loader of this plugin.
* @example ReleaseStage.PROD
*/
stage: ReleaseStage;
};
// todo(himself65): support Vue.js
export type Adapter<Props extends Record<string, unknown>> = (
props: Props
) => ReactElement;
export type AffinePluginContext = {
toast: (text: string) => void;
};
export type BaseProps = {
contentLayoutAtom: ContentLayoutAtom;
};
export type PluginUIAdapter = {
sidebarItem: Adapter<BaseProps>;
headerItem: Adapter<BaseProps>;
detailContent: Adapter<BaseProps>;
debugContent: Adapter<Record<string, unknown>>;
};
export type PluginAdapterCreator = (
context: AffinePluginContext
) => PluginUIAdapter;
export type AffinePlugin<ID extends string> = {
definition: Definition<ID>;
uiAdapter: Partial<PluginUIAdapter>;
};

View File

@@ -0,0 +1,224 @@
// Copied from @types/webpack-env
/**
* Webpack module API - variables and global functions available inside modules
*/
declare namespace __WebpackModuleApi {
type ModuleId = any;
interface HotNotifierInfo {
type:
| 'self-declined'
| 'declined'
| 'unaccepted'
| 'accepted'
| 'disposed'
| 'accept-errored'
| 'self-accept-errored'
| 'self-accept-error-handler-errored';
/**
* The module in question.
*/
moduleId: number;
/**
* For errors: the module id owning the accept handler.
*/
dependencyId?: number | undefined;
/**
* For declined/accepted/unaccepted: the chain from where the update was propagated.
*/
chain?: number[] | undefined;
/**
* For declined: the module id of the declining parent
*/
parentId?: number | undefined;
/**
* For accepted: the modules that are outdated and will be disposed
*/
outdatedModules?: number[] | undefined;
/**
* For accepted: The location of accept handlers that will handle the update
*/
outdatedDependencies?:
| {
[dependencyId: number]: number[];
}
| undefined;
/**
* For errors: the thrown error
*/
error?: Error | undefined;
/**
* For self-accept-error-handler-errored: the error thrown by the module
* before the error handler tried to handle it.
*/
originalError?: Error | undefined;
}
interface Hot {
/**
* Accept code updates for the specified dependencies. The callback is called when dependencies were replaced.
* @param dependencies
* @param callback
* @param errorHandler
*/
accept(
dependencies: string[],
callback?: (updatedDependencies: ModuleId[]) => void,
errorHandler?: (err: Error) => void
): void;
/**
* Accept code updates for the specified dependencies. The callback is called when dependencies were replaced.
* @param dependency
* @param callback
* @param errorHandler
*/
accept(
dependency: string,
callback?: () => void,
errorHandler?: (err: Error) => void
): void;
/**
* Accept code updates for this module without notification of parents.
* This should only be used if the module doesnt export anything.
* The errHandler can be used to handle errors that occur while loading the updated module.
* @param errHandler
*/
accept(errHandler?: (err: Error) => void): void;
/**
* Do not accept updates for the specified dependencies. If any dependencies is updated, the code update fails with code "decline".
*/
decline(dependencies: string[]): void;
/**
* Do not accept updates for the specified dependencies. If any dependencies is updated, the code update fails with code "decline".
*/
decline(dependency: string): void;
/**
* Flag the current module as not update-able. If updated the update code would fail with code "decline".
*/
decline(): void;
/**
* Add a one time handler, which is executed when the current module code is replaced.
* Here you should destroy/remove any persistent resource you have claimed/created.
* If you want to transfer state to the new module, add it to data object.
* The data will be available at module.hot.data on the new module.
* @param callback
*/
dispose(callback: (data: any) => void): void;
dispose(callback: <T>(data: T) => void): void;
/**
* Add a one time handler, which is executed when the current module code is replaced.
* Here you should destroy/remove any persistent resource you have claimed/created.
* If you want to transfer state to the new module, add it to data object.
* The data will be available at module.hot.data on the new module.
* @param callback
*/
addDisposeHandler(callback: (data: any) => void): void;
addDisposeHandler<T>(callback: (data: T) => void): void;
/**
* Remove a handler.
* This can useful to add a temporary dispose handler. You could i. e. replace code while in the middle of a multi-step async function.
* @param callback
*/
removeDisposeHandler(callback: (data: any) => void): void;
removeDisposeHandler<T>(callback: (data: T) => void): void;
/**
* Throws an exceptions if status() is not idle.
* Check all currently loaded modules for updates and apply updates if found.
* If no update was found, the callback is called with null.
* If autoApply is truthy the callback will be called with all modules that were disposed.
* apply() is automatically called with autoApply as options parameter.
* If autoApply is not set the callback will be called with all modules that will be disposed on apply().
* @param autoApply
* @param callback
*/
check(
autoApply: boolean,
callback: (err: Error, outdatedModules: ModuleId[]) => void
): void;
/**
* Throws an exceptions if status() is not idle.
* Check all currently loaded modules for updates and apply updates if found.
* If no update was found, the callback is called with null.
* The callback will be called with all modules that will be disposed on apply().
* @param callback
*/
check(callback: (err: Error, outdatedModules: ModuleId[]) => void): void;
/**
* If status() != "ready" it throws an error.
* Continue the update process.
* @param options
* @param callback
*/
apply(
options: AcceptOptions,
callback: (err: Error, outdatedModules: ModuleId[]) => void
): void;
/**
* If status() != "ready" it throws an error.
* Continue the update process.
* @param callback
*/
apply(callback: (err: Error, outdatedModules: ModuleId[]) => void): void;
/**
* Return one of idle, check, watch, watch-delay, prepare, ready, dispose, apply, abort or fail.
*/
status(): string;
/** Register a callback on status change. */
status(callback: (status: string) => void): void;
/** Register a callback on status change. */
addStatusHandler(callback: (status: string) => void): void;
/**
* Remove a registered status change handler.
* @param callback
*/
removeStatusHandler(callback: (status: string) => void): void;
active: boolean;
data: any;
}
interface AcceptOptions {
/**
* If true the update process continues even if some modules are not accepted (and would bubble to the entry point).
*/
ignoreUnaccepted?: boolean | undefined;
/**
* Ignore changes made to declined modules.
*/
ignoreDeclined?: boolean | undefined;
/**
* Ignore errors throw in accept handlers, error handlers and while reevaluating module.
*/
ignoreErrored?: boolean | undefined;
/**
* Notifier for declined modules.
*/
onDeclined?: ((info: HotNotifierInfo) => void) | undefined;
/**
* Notifier for unaccepted modules.
*/
onUnaccepted?: ((info: HotNotifierInfo) => void) | undefined;
/**
* Notifier for accepted modules.
*/
onAccepted?: ((info: HotNotifierInfo) => void) | undefined;
/**
* Notifier for disposed modules.
*/
onDisposed?: ((info: HotNotifierInfo) => void) | undefined;
/**
* Notifier for errors.
*/
onErrored?: ((info: HotNotifierInfo) => void) | undefined;
/**
* Indicates that apply() is automatically called by check function
*/
autoApply?: boolean | undefined;
}
}
interface ImportMeta {
/**
* `import.meta.webpackHot` is an alias for` module.hot` which is also available in strict ESM
*/
webpackHot?: __WebpackModuleApi.Hot | undefined;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}