后端:新增主题配置获取接口。

前端:配置了默认环境变量值,创建自定义路由,将契约和服务合并到核心
This commit is contained in:
lsy 2024-11-27 01:02:05 +08:00
parent dbdfbf5d8a
commit b689233e91
26 changed files with 329 additions and 437 deletions

View File

@ -17,7 +17,7 @@ use std::sync::Arc;
pub struct SystemConfigure {
pub author_name: String,
pub current_theme: String,
pub site_keyword: Vec<String>,
pub site_keyword: String,
pub site_description: String,
pub admin_path: String,
}
@ -27,7 +27,7 @@ impl Default for SystemConfigure {
Self {
author_name: "lsy".to_string(),
current_theme: "default".to_string(),
site_keyword: vec!["echoes".to_string()],
site_keyword: "echoes".to_string(),
site_description: "echoes是一个高效、可扩展的博客平台".to_string(),
admin_path: "admin".to_string(),
}
@ -91,8 +91,21 @@ pub async fn system_config_get(
_token: SystemToken,
) -> AppResult<Json<Value>> {
let sql = state.sql_get().await.into_app_result()?;
let configure = get_setting(&sql, "system".to_string(), "settings".to_string())
let settings = get_setting(&sql, "system".to_string(), "settings".to_string())
.await
.into_app_result()?;
Ok(configure)
Ok(settings)
}
#[get("/theme/<name>")]
pub async fn theme_config_get(
state: &State<Arc<AppState>>,
_token: SystemToken,
name: String,
) -> AppResult<Json<Value>> {
let sql = state.sql_get().await.into_app_result()?;
let settings = get_setting(&sql, "theme".to_string(), name)
.await
.into_app_result()?;
Ok(settings)
}

View File

@ -8,14 +8,10 @@
interface ImportMetaEnv {
readonly VITE_SERVER_API: string; // 用于访问API的基础URL
readonly VITE_THEME_PATH: string; // 存储主题文件的目录路径
readonly VITE_CONTENT_PATH: string; //mark文章存储的位置
readonly VITE_CONTENT_STATIC_PATH: string; //导出文章静态存储的位置
readonly VITE_PLUGINS_PATH: string; // 存储插件文件的目录路径
readonly VITE_ASSETS_PATH: string; // 存储静态资源的目录路径
readonly VITE_SYSTEM_PORT: number; // 系统端口
VITE_SYSTEM_USERNAME: string; // 前端账号名称
VITE_SYSTEM_PASSWORD: string; // 前端账号密码
VITE_SYSTEM_STATUS: boolean; // 系统是否进行安装
VITE_INIT_STATUS: boolean; // 系统是否进行安装
}
interface ImportMeta {

3
frontend/app/init.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function page(){
return <></>
}

View File

@ -2,25 +2,13 @@ import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import "./tailwind.css";
import { BaseProvider } from "hooks/servicesProvider";
import { LinksFunction } from "@remix-run/react/dist/routeModules";
export const links: LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
import "~/tailwind.css";
export function Layout({ children }: { children: React.ReactNode }) {
return (
@ -28,18 +16,23 @@ export function Layout({ children }: { children: React.ReactNode }) {
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content="echoes" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
return (
<BaseProvider>
<Layout>
<Outlet />
</Layout>
</BaseProvider>
);
}

15
frontend/app/routes.tsx Normal file
View File

@ -0,0 +1,15 @@
import { useState } from "react";
import ReactDOMServer from 'react-dom/server';
import { useLocation } from 'react-router-dom';
const MyComponent = () => {
return <div>Hello, World!</div>;
};
export default function Routes() {
const htmlString = ReactDOMServer.renderToString(<MyComponent />);
return (<div></div>)
}

View File

@ -1,138 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};
export default function Index() {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-16">
<header className="flex flex-col items-center gap-9">
<h1 className="leading text-2xl font-bold text-gray-800 dark:text-gray-100">
Welcome to <span className="sr-only">Remix</span>
</h1>
<div className="h-[144px] w-[434px]">
<img
src="/logo-light.png"
alt="Remix"
className="block w-full dark:hidden"
/>
<img
src="/logo-dark.png"
alt="Remix"
className="hidden w-full dark:block"
/>
</div>
</header>
<nav className="flex flex-col items-center justify-center gap-4 rounded-3xl border border-gray-200 p-6 dark:border-gray-700">
<p className="leading-6 text-gray-700 dark:text-gray-200">
What&apos;s next?
</p>
<ul>
{resources.map(({ href, text, icon }) => (
<li key={href}>
<a
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
href={href}
target="_blank"
rel="noreferrer"
>
{icon}
{text}
</a>
</li>
))}
</ul>
</nav>
</div>
</div>
);
}
const resources = [
{
href: "https://remix.run/start/quickstart",
text: "Quick Start (5 min)",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M8.51851 12.0741L7.92592 18L15.6296 9.7037L11.4815 7.33333L12.0741 2L4.37036 10.2963L8.51851 12.0741Z"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
href: "https://remix.run/start/tutorial",
text: "Tutorial (30 min)",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M4.561 12.749L3.15503 14.1549M3.00811 8.99944H1.01978M3.15503 3.84489L4.561 5.2508M8.3107 1.70923L8.3107 3.69749M13.4655 3.84489L12.0595 5.2508M18.1868 17.0974L16.635 18.6491C16.4636 18.8205 16.1858 18.8205 16.0144 18.6491L13.568 16.2028C13.383 16.0178 13.0784 16.0347 12.915 16.239L11.2697 18.2956C11.047 18.5739 10.6029 18.4847 10.505 18.142L7.85215 8.85711C7.75756 8.52603 8.06365 8.21994 8.39472 8.31453L17.6796 10.9673C18.0223 11.0653 18.1115 11.5094 17.8332 11.7321L15.7766 13.3773C15.5723 13.5408 15.5554 13.8454 15.7404 14.0304L18.1868 16.4767C18.3582 16.6481 18.3582 16.926 18.1868 17.0974Z"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
href: "https://remix.run/docs",
text: "Remix Docs",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
),
},
{
href: "https://rmx.as/discord",
text: "Join Discord",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
strokeWidth="1.5"
/>
</svg>
),
},
];

View File

@ -0,0 +1,21 @@
export type Serializable =
| null
| number
| string
| boolean
| { [key: string]: Serializable }
| Array<Serializable>;
export interface Configuration {
[key: string]: {
title: string;
description?: string;
data: Serializable;
};
}
export interface PathDescription {
path: string;
name: string;
description?: string;
}

View File

@ -1,5 +0,0 @@
export interface CapabilityProps<T> {
name: string;
description?: string;
execute: (...args: any[]) => Promise<T>;
}

View File

@ -1,14 +0,0 @@
export type SerializeType =
| null
| number
| string
| boolean
| { [key: string]: SerializeType }
| Array<SerializeType>;
export interface Configuration {
[key: string]: {
title: string;
description?: string;
data: SerializeType;
};
}

View File

@ -1,17 +0,0 @@
import { Configuration } from "contracts/generalContract";
export interface PluginConfig {
name: string;
version: string;
displayName: string;
description?: string;
author?: string;
enabled: boolean;
icon?: string;
managePath?: string;
configuration?: Configuration;
routs: Set<{
description?: string;
path: string;
}>;
}

View File

@ -1,11 +0,0 @@
export interface TemplateContract {
name: string;
description?: string;
config: {
layout?: string;
styles?: string[];
scripts?: string[];
};
loader: () => Promise<void>;
element: () => React.ReactNode;
}

View File

@ -1,30 +0,0 @@
import { Configuration } from "contracts/generalContract";
export interface ThemeConfig {
name: string;
displayName: string;
icon?: string;
version: string;
description?: string;
author?: string;
templates: Map<string, ThemeTemplate>;
globalSettings?: {
layout?: string;
css?: string;
};
configuration: Configuration;
routes: {
index: string;
post: string;
tag: string;
category: string;
error: string;
loding: string;
page: Map<string, string>;
};
}
export interface ThemeTemplate {
path: string;
name: string;
description?: string;
}

View File

@ -1,4 +1,9 @@
import { CapabilityProps } from "contracts/capabilityContract";
export interface CapabilityProps<T> {
name: string;
description?: string;
execute: (...args: any[]) => Promise<T>;
}
export class CapabilityService {
private capabilities: Map<

View File

@ -1,10 +1,24 @@
import { PluginConfiguration } from "types/pluginRequirement";
import { Contracts } from "contracts/capabilityContract";
export interface PluginConfig {
name: string;
version: string;
displayName: string;
description?: string;
author?: string;
enabled: boolean;
icon?: string;
managePath?: string;
configuration?: Configuration;
routes: Set<{
description?: string;
path: string;
}>;
}
export class PluginManager {
private plugins: Map<string, PluginProps> = new Map();
private configurations: Map<string, PluginConfiguration> = new Map();
private extensions: Map<string, ExtensionProps> = new Map();
private configurations: Map<string, PluginConfig> = new Map();
async loadPlugins() {
const pluginDirs = await this.scanPluginDirectory();
@ -37,7 +51,7 @@ export class PluginManager {
async getPluginConfig(
pluginName: string,
): Promise<PluginConfiguration | undefined> {
): Promise<PluginConfig | undefined> {
const dbConfig = await this.fetchConfigFromDB(pluginName);
if (dbConfig) {
return dbConfig;

38
frontend/core/route.ts Normal file
View File

@ -0,0 +1,38 @@
import { ReactNode } from "react"; // Import React
import { LoaderFunction } from "react-router-dom";
interface RouteElement {
element: ReactNode,
loader?: LoaderFunction,
children?: RouteElement[],
}
export class RouteManager {
private static instance: RouteManager;
private routes = new Map<string, RouteElement>();
private routesCache = new Map<string, string>();
private constructor() { }
public static getInstance(): RouteManager {
if (!RouteManager.instance) {
RouteManager.instance = new RouteManager();
}
return RouteManager.instance;
}
private createRouteElement(
path: string,
element: RouteElement
) {
this.routes.set(path, element);
}
private getRoutes(path: string): RouteElement | undefined {
return this.routes.get(path);
}
private getRoutesCache(path: string): string | undefined {
return this.routesCache.get(path);
}
}

98
frontend/core/theme.ts Normal file
View File

@ -0,0 +1,98 @@
import { Configuration, PathDescription } from "common/serializableType";
import { ApiService } from "./api";
export interface ThemeConfig {
name: string;
displayName: string;
icon?: string;
version: string;
description?: string;
author?: string;
templates: Map<string, PathDescription>;
globalSettings?: {
layout?: string;
css?: string;
};
configuration: Configuration;
routes: {
index: string;
post: string;
tag: string;
category: string;
error: string;
loading: string;
page: Map<string, string>;
};
}
export interface Template {
name: string;
description?: string;
config: {
layout?: string;
styles?: string[];
scripts?: string[];
};
loader: () => Promise<void>;
element: () => React.ReactNode;
}
export class ThemeService {
private static instance: ThemeService;
private currentTheme?: ThemeConfig;
private api: ApiService;
private constructor(api: ApiService) {
this.api = api;
}
public static getInstance(api?: ApiService): ThemeService {
if (!ThemeService.instance && api) {
ThemeService.instance = new ThemeService(api);
}
return ThemeService.instance;
}
public async getCurrentTheme(): Promise<void> {
try {
const themeConfig = await this.api.request<ThemeConfig>(
"/theme/current",
{ method: "GET" },
);
this.currentTheme = themeConfig;
} catch (error) {
console.error("Failed to initialize theme:", error);
throw error;
}
}
public getThemeConfig(): ThemeConfig | undefined {
return this.currentTheme;
}
public async updateThemeConfig(config: Partial<ThemeConfig>): Promise<void> {
try {
const updatedConfig = await this.api.request<ThemeConfig>(
"/theme/config",
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
},
);
await this.loadTheme(updatedConfig);
} catch (error) {
console.error("Failed to update theme configuration:", error);
throw error;
}
}
}

View File

@ -1,6 +1,6 @@
import { CapabilityService } from "services/capabilityService";
import { ThemeService } from "services/themeService";
import { ApiService } from "services/apiService";
import { CapabilityService } from "core/capability";
import { ApiService } from "core/api";
import { RouteManager } from "core/route";
import { createServiceContext } from "hooks/createServiceContext";
import { ReactNode } from "react";
@ -9,18 +9,18 @@ export const { CapabilityProvider, useCapability } = createServiceContext(
() => CapabilityService.getInstance(),
);
export const { ThemeProvider, useTheme } = createServiceContext("Theme", () =>
ThemeService.getInstance(),
export const { RouteProvider, useRoute } = createServiceContext("Route", () =>
RouteManager.getInstance(),
);
export const { ApiProvider, useApi } = createServiceContext("Api", () =>
ThemeService.getInstance(),
ApiService.getInstance(),
);
export const ServiceProvider = ({ children }: { children: ReactNode }) => (
export const BaseProvider = ({ children }: { children: ReactNode }) => (
<ApiProvider>
<CapabilityProvider>
<ThemeProvider>{children}</ThemeProvider>
<RouteProvider>{children}</RouteProvider>
</CapabilityProvider>
</ApiProvider>
);

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -1,34 +0,0 @@
import React from "react"; // Import React
import { LoaderFunction, RouteObject } from "react-router-dom";
export class RouteManager {
private static instance: RouteManager;
private routes: RouteObject[] = [];
private constructor() {}
public static getInstance(): RouteManager {
if (!RouteManager.instance) {
RouteManager.instance = new RouteManager();
}
return RouteManager.instance;
}
private createRouteElement(
path: string,
element: React.ReactNode,
loader?: LoaderFunction,
children?: RouteObject[],
) {
this.routes.push({
path,
element,
loader,
children,
});
}
private getRoutes(): RouteObject[] {
return this.routes;
}
}

View File

@ -1,123 +0,0 @@
import { ThemeConfig, ThemeTemplate } from "contracts/themeContract";
import { ApiService } from "./apiService";
export class ThemeService {
private static instance: ThemeService;
private currentTheme?: ThemeConfig;
private api: ApiService;
private constructor(api: ApiService) {
this.api = api;
}
public static getInstance(api?: ApiService): ThemeService {
if (!ThemeService.instance && api) {
ThemeService.instance = new ThemeService(api);
}
return ThemeService.instance;
}
public async initialize(): Promise<void> {
try {
const themeConfig = await this.api.request<ThemeConfig>(
"/theme/current",
{ method: "GET" },
);
await this.loadTheme(themeConfig);
} catch (error) {
console.error("Failed to initialize theme:", error);
throw error;
}
}
private async loadTheme(config: ThemeConfig): Promise<void> {
try {
this.currentTheme = config;
await this.loadTemplates();
} catch (error) {
console.error("Failed to load theme:", error);
throw error;
}
}
private async loadTemplates(): Promise<void> {
if (!this.currentTheme) {
throw new Error("No theme configuration loaded");
}
const loadTemplate = async (template: ThemeTemplate) => {
try {
const response = await fetch(template.path);
const templateContent = await response.text();
this.templates.set(template.name, templateContent);
} catch (error) {
console.error(`Failed to load template ${template.name}:`, error);
throw error;
}
};
const loadPromises = Array.from(this.currentTheme.templates.values()).map(
(template) => loadTemplate(template),
);
await Promise.all(loadPromises);
}
public getThemeConfig(): ThemeConfig | undefined {
return this.currentTheme;
}
public getTemplate(templateName: string): string {
const template = this.templates.get(templateName);
if (!template) {
throw new Error(`Template ${templateName} not found`);
}
return template;
}
public getTemplateByRoute(route: string): string {
if (!this.currentTheme) {
throw new Error("No theme configuration loaded");
}
let templateName: string | undefined;
if (route === "/") {
templateName = this.currentTheme.routes.index;
} else if (route.startsWith("/post/")) {
templateName = this.currentTheme.routes.post;
} else if (route.startsWith("/tag/")) {
templateName = this.currentTheme.routes.tag;
} else if (route.startsWith("/category/")) {
templateName = this.currentTheme.routes.category;
} else {
templateName = this.currentTheme.routes.page.get(route);
}
if (!templateName) {
templateName = this.currentTheme.routes.error;
}
return this.getTemplate(templateName);
}
public async updateThemeConfig(config: Partial<ThemeConfig>): Promise<void> {
try {
const updatedConfig = await this.api.request<ThemeConfig>(
"/theme/config",
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
},
);
await this.loadTheme(updatedConfig);
} catch (error) {
console.error("Failed to update theme configuration:", error);
throw error;
}
}
}

View File

@ -1,7 +1,7 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
content: ["./app/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
fontFamily: {

View File

@ -1,24 +1,43 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import Routes from "~/routes"
declare module "@remix-run/node" {
interface Future {
v3_singleFetch: true;
}
}
export default defineConfig({
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
});
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
routes: (defineRoutes) => {
return defineRoutes((route) => {
if (!env.VITE_INIT_STATUS) {
route("/", "init.tsx", { id: "index-route" });
route("*", "init.tsx", { id: "catch-all-route" });
}
else {
route("/", "routes.tsx", { id: "index-route" });
route("*", "routes.tsx", { id: "catch-all-route" });
}
});
}
}),
tsconfigPaths(),
],
define: {
"import.meta.env.VITE_SYSTEM_STATUS": JSON.stringify(false),
"import.meta.env.VITE_SERVER_API": JSON.stringify("localhost:22000"),
"import.meta.env.VITE_SYSTEM_PORT": JSON.stringify(22100),
},
server: {
port: Number(env.VITE_SYSTEM_PORT ?? 22100),
strictPort: true,
},
};
});

19
src/hooks/useService.ts Normal file
View File

@ -0,0 +1,19 @@
export function createServiceHook<T>(name: string, getInstance: () => T) {
const Context = createContext<T | null>(null);
const Provider: FC<PropsWithChildren> = ({ children }) => (
<Context.Provider value={getInstance()}>
{children}
</Context.Provider>
);
const useService = () => {
const service = useContext(Context);
if (!service) {
throw new Error(`use${name} must be used within ${name}Provider`);
}
return service;
};
return [Provider, useService] as const;
}

13
src/types/config.ts Normal file
View File

@ -0,0 +1,13 @@
export type Json =
| null
| number
| string
| boolean
| { [key: string]: Json }
| Json[];
export type Config = Record<string, {
title: string;
description?: string;
value: Json;
}>;

17
src/types/plugin.ts Normal file
View File

@ -0,0 +1,17 @@
export interface PluginDefinition {
meta: {
name: string;
version: string;
displayName: string;
description?: string;
author?: string;
icon?: string;
};
config?: Config;
routes: {
path: string;
description?: string;
}[];
enabled: boolean;
managePath?: string;
}