mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
ui-next: page registration (#1159)
This commit is contained in:
1 parent
66a8a0ab9e
commit
bbd7fbc852
28 files changed
+238
-98
No files matched your search
+2
-2
@@ -49,8 +49,8 @@ packages/ui-default/public
|
||||
packages/ui-default/misc/.iconfont
|
||||
packages/ui-default/static/locale
|
||||
packages/ui-next/public
|
||||
plugins/
|
||||
modules/
|
||||
/plugins/
|
||||
/modules/
|
||||
|
||||
# Data files
|
||||
*.mmdb
|
||||
|
||||
@@ -159,9 +159,6 @@ const UINextConfig = {
|
||||
'@/*': [
|
||||
'./packages/ui-next/src/*',
|
||||
],
|
||||
'vj/*': [
|
||||
'./packages/ui-default/*',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@hydrooj/ui-next-plugin",
|
||||
"version": "0.0.0",
|
||||
"author": "Baoshuo <i@baoshuo.ren>",
|
||||
"license": "AGPL-3.0",
|
||||
"hydro": {
|
||||
"cli": false
|
||||
},
|
||||
"dependencies": {
|
||||
"@hydrooj/ui-next": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PluginAPI } from '@hydrooj/ui-next';
|
||||
|
||||
export function setup(api: PluginAPI) {
|
||||
api.before('page:app', () => {
|
||||
console.log('before app');
|
||||
// throw new Error('test error boundary in before interceptor');
|
||||
return <div>before app via @hydrooj/ui-next-plugin-sample</div>;
|
||||
});
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"packages/*",
|
||||
"framework/*",
|
||||
"plugins/*",
|
||||
"modules/*"
|
||||
"modules/*",
|
||||
"examples/plugins/*"
|
||||
],
|
||||
"main": "package.json",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "@hydrooj/ui-next-plugin-sample",
|
||||
"dependencies": {
|
||||
"@hydrooj/ui-next": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default function BeforeComponent() {
|
||||
console.log('before app');
|
||||
// throw new Error('test error boundary in before interceptor');
|
||||
return <div>before app via @hydrooj/ui-next-plugin-sample</div>;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { PluginAPI } from '@hydrooj/ui-next';
|
||||
import BeforeComponent from './before';
|
||||
|
||||
export function setup(api: PluginAPI) {
|
||||
api.before('page:app', BeforeComponent);
|
||||
}
|
||||
+37
-29
@@ -1,7 +1,6 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import esbuild from 'esbuild';
|
||||
import c2k from 'koa2-connect/ts';
|
||||
import { createServer, type Plugin } from 'vite';
|
||||
@@ -28,6 +27,20 @@ const PENDING_HTML = `<html>
|
||||
const INJECT_MARKER = '<!-- __HYDRO_INJECTION__DO_NOT_REMOVE_THIS__ -->';
|
||||
const buildInject = (data: string) => `<script id="__HYDRO_INJECTION__" type="application/json">${data}</script>`;
|
||||
|
||||
function getAddonEntries(): Record<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [name, addon] of Object.entries(global.addons)) {
|
||||
const uiEntry = ['ui/index.ts', 'ui/index.tsx', 'ui/index.js', 'ui/index.jsx']
|
||||
.map((f) => path.resolve(addon as string, f))
|
||||
.find((f) => fs.existsSync(f));
|
||||
if (uiEntry) {
|
||||
logger.info('UI entry for addon %s: %s', name, uiEntry);
|
||||
entries[name] = uiEntry;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function hydroPlugins(): Plugin {
|
||||
const virtualModuleId = 'virtual:hydro-plugins';
|
||||
const resolvedVirtualModuleId = `\0${virtualModuleId}`;
|
||||
@@ -42,14 +55,12 @@ function hydroPlugins(): Plugin {
|
||||
},
|
||||
load(id) {
|
||||
if (id === resolvedVirtualModuleId) {
|
||||
const entries: string[] = [];
|
||||
for (const addon of Object.values(global.addons)) {
|
||||
const uiEntry = path.resolve(addon, 'ui', 'index.ts');
|
||||
if (fs.existsSync(uiEntry)) entries.push(uiEntry);
|
||||
}
|
||||
if (!entries.length) return 'export default [];';
|
||||
const imports = entries.map((e, i) => `import * as plugin${i} from '${e}';`).join('\n');
|
||||
const exports = `export default [${entries.map((_, i) => `plugin${i}`).join(', ')}];`;
|
||||
const entries = getAddonEntries();
|
||||
if (!Object.keys(entries).length) return 'export default [];';
|
||||
const imports = Object.entries(entries).map(([_, e], i) => `import * as plugin${i} from '${e}';`).join('\n');
|
||||
const exports = `export default [${Object.entries(entries).map(([addon, _], i) => {
|
||||
return `{ name: '${addon}', ...plugin${i} }`;
|
||||
}).join(', ')}];`;
|
||||
return `${imports}\n${exports}`;
|
||||
}
|
||||
return undefined;
|
||||
@@ -105,12 +116,9 @@ class UiNextConstantHandler extends Handler {
|
||||
export async function buildPlugins() {
|
||||
const start = Date.now();
|
||||
let totalSize = 0;
|
||||
const entries: string[] = [];
|
||||
for (const addon of Object.values(global.addons)) {
|
||||
const uiEntry = path.resolve(addon as string, 'ui', 'index.ts');
|
||||
if (fs.existsSync(uiEntry)) entries.push(uiEntry);
|
||||
}
|
||||
if (!entries.length) {
|
||||
const entries = getAddonEntries();
|
||||
|
||||
if (!Object.keys(entries).length) {
|
||||
vfs['plugins.js'] = 'window.__hydroPlugins = [];';
|
||||
hashes['plugins.js'] = '00000000';
|
||||
logger.info('No plugins to build');
|
||||
@@ -121,11 +129,8 @@ export async function buildPlugins() {
|
||||
const result = await esbuild.build({
|
||||
stdin: {
|
||||
contents: [
|
||||
...entries.map((e, i) => `import * as plugin${i} from '${e}';`),
|
||||
`window.__hydroPlugins = [${entries.map((e, i) => {
|
||||
const addonName = path.basename(path.resolve(e, '..', '..'));
|
||||
return `{ name: '${addonName}', ...plugin${i} }`;
|
||||
}).join(', ')}];`,
|
||||
...Object.entries(entries).map(([_, e], i) => `import * as plugin${i} from '${e}';`),
|
||||
`window.__hydroPlugins = [${Object.entries(entries).map(([n], i) => `{ name: '${n}', ...plugin${i} }`).join(', ')}];`,
|
||||
].join('\n'),
|
||||
resolveDir: process.cwd(),
|
||||
loader: 'ts',
|
||||
@@ -155,7 +160,7 @@ export async function apply(ctx: Context) {
|
||||
|
||||
if (process.env.DEV) {
|
||||
const vite = await createServer({
|
||||
configFile: false,
|
||||
root: __dirname,
|
||||
clearScreen: false,
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
@@ -168,12 +173,7 @@ export async function apply(ctx: Context) {
|
||||
},
|
||||
},
|
||||
appType: 'custom',
|
||||
root: __dirname,
|
||||
base: '/',
|
||||
plugins: [react(), hydroPlugins()],
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
plugins: [hydroPlugins()],
|
||||
});
|
||||
const middleware = c2k(vite.middlewares);
|
||||
const capture = ['/@vite/', '/src/', '/node_modules/', '/@react-refresh', '/@fs', '/@id/'];
|
||||
@@ -191,7 +191,11 @@ export async function apply(ctx: Context) {
|
||||
const serialized = JSON.stringify({
|
||||
HYDRO_INJECTED: true,
|
||||
name: context.handler.context._matchedRouteName,
|
||||
args,
|
||||
args: {
|
||||
UserContext: context.UserContext,
|
||||
UiContext: context.handler.UiContext,
|
||||
...args,
|
||||
},
|
||||
url: context.handler.context.req.url!,
|
||||
route_map: ctx.server.routeMap,
|
||||
endpoint: ctx.setting.get('server.url') || undefined,
|
||||
@@ -220,7 +224,11 @@ export async function apply(ctx: Context) {
|
||||
const serialized = JSON.stringify({
|
||||
HYDRO_INJECTED: true,
|
||||
name: context.handler.context._matchedRouteName,
|
||||
args,
|
||||
args: {
|
||||
UserContext: context.UserContext,
|
||||
UiContext: context.handler.UiContext,
|
||||
...args,
|
||||
},
|
||||
url: context.handler.context.req.url!,
|
||||
route_map: ctx.server.routeMap,
|
||||
endpoint: ctx.setting.get('server.url') || undefined,
|
||||
|
||||
@@ -4,7 +4,7 @@ export { Link, type LinkProps } from './components/link';
|
||||
// Context
|
||||
export { type PageData, usePageData } from './context/page-data';
|
||||
export { type RouterState, useNavigate, useRouterState } from './context/router';
|
||||
export { useUrl } from './hooks/use-url';
|
||||
export { useBuildUrl } from './hooks/use-build-url';
|
||||
|
||||
// Registry
|
||||
export type {
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import { Link } from './components/link';
|
||||
import { Suspense, useMemo, useSyncExternalStore } from 'react';
|
||||
import { usePageData } from './context/page-data';
|
||||
import { defineSlot } from './registry';
|
||||
import { defineSlot, SlotName } from './registry';
|
||||
import { SlotErrorBoundary } from './registry/error-boundary';
|
||||
import { store } from './registry/store';
|
||||
|
||||
const AppInner = defineSlot('page:app', () => {
|
||||
const data = usePageData();
|
||||
const App = defineSlot('app:root', () => {
|
||||
const { name } = usePageData();
|
||||
const slotName: SlotName = `page:${name}`;
|
||||
|
||||
const [subscribe, getSnapshot] = useMemo(() => [
|
||||
(cb: () => void) => store.subscribe(slotName, cb),
|
||||
() => store.getVersion(slotName),
|
||||
], [slotName]);
|
||||
|
||||
useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
const Comp = store.getDefault(slotName);
|
||||
|
||||
if (!Comp) {
|
||||
return (
|
||||
<div>
|
||||
Page not found: <code>{name}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>app, page:{data.name}</div>
|
||||
<div>
|
||||
<Link to="homepage">homepage</Link> <Link to="problem_main">problem_main</Link>
|
||||
</div>
|
||||
</>
|
||||
<SlotErrorBoundary slotName={slotName} label="renderer">
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Comp />
|
||||
</Suspense>
|
||||
</SlotErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppInner;
|
||||
export default App;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineSlot } from '../registry';
|
||||
|
||||
const Layout = defineSlot('app:layout', ({ children }: React.PropsWithChildren) => <>{children}</>);
|
||||
|
||||
export default Layout;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from '../context/router';
|
||||
import { useUrl } from '../hooks/use-url';
|
||||
import { type UrlParams, useBuildUrl } from '../hooks/use-build-url';
|
||||
|
||||
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
||||
/** Pre-built href. Use this or `to`, not both. */
|
||||
@@ -8,15 +8,20 @@ export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorEle
|
||||
/** Route name to resolve via the route map. */
|
||||
to?: string;
|
||||
/** Params for route resolution when `to` is given. */
|
||||
params?: Record<string, string>;
|
||||
params?: UrlParams;
|
||||
}
|
||||
|
||||
function isModifiedEvent(e: React.MouseEvent): boolean {
|
||||
return e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||
}
|
||||
|
||||
export const Link: React.FC<React.PropsWithChildren<LinkProps>> = ({ href, to, params, onClick, target, download = false, children, ...rest }) => {
|
||||
const buildUrl = useUrl();
|
||||
export const Link: React.FC<React.PropsWithChildren<LinkProps>> = ({
|
||||
href, to, params,
|
||||
onClick, target, download = false,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const buildUrl = useBuildUrl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const resolvedHref = useMemo(() => (to ? buildUrl(to, params) : (href ?? '#')), [buildUrl, href, to, params]);
|
||||
|
||||
@@ -4,7 +4,11 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from 're
|
||||
|
||||
export interface PageData {
|
||||
name: string;
|
||||
args: Record<string, any>;
|
||||
args: {
|
||||
UserContext: Record<string, any>;
|
||||
UiContext: Record<string, any>;
|
||||
[key: string]: any;
|
||||
};
|
||||
url: string;
|
||||
}
|
||||
|
||||
@@ -40,3 +44,11 @@ export function usePageData(): PageData {
|
||||
export function useSetPageData(): React.Dispatch<React.SetStateAction<PageData>> {
|
||||
return usePageDataContext().setData;
|
||||
}
|
||||
|
||||
export function useUiContext(): PageData['args']['UiContext'] {
|
||||
return usePageDataContext().data.args.UiContext;
|
||||
}
|
||||
|
||||
export function useUserContext(): PageData['args']['UserContext'] {
|
||||
return usePageDataContext().data.args.UserContext;
|
||||
}
|
||||
@@ -16,7 +16,6 @@ export const isInjected: boolean = !!injectionData.HYDRO_INJECTED;
|
||||
export const hydroDomains: string[] = injectionData.hydro_domains ?? [];
|
||||
export const pluginsUrl: string | undefined = injectionData.plugins_url;
|
||||
|
||||
// routeMap as an external store for useSyncExternalStore, with HMR state preservation
|
||||
interface RouteMapStore {
|
||||
_routeMap: Record<string, string>;
|
||||
_listeners: Set<() => void>;
|
||||
@@ -65,6 +64,6 @@ export const endpointOrigins = new Set(endpoints.map((ep) => new URL(ep).origin)
|
||||
|
||||
export const initialPage: PageData = {
|
||||
name: (injectionData.name as string) || '',
|
||||
args: (injectionData.args as Record<string, any>) || {},
|
||||
args: (injectionData.args as any) || {},
|
||||
url: (injectionData.url as string) || (window.location.pathname + window.location.search),
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { compile } from 'path-to-regexp';
|
||||
import { useCallback } from 'react';
|
||||
import { useUiContext } from '@/context/page-data';
|
||||
import { useRouteMap } from './use-route-map';
|
||||
|
||||
export interface UrlParams {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export function useBuildUrl() {
|
||||
const routeMap = useRouteMap();
|
||||
const { domainId, domain } = useUiContext();
|
||||
|
||||
const getPrefix = useCallback((id?: string) => {
|
||||
id ||= domainId;
|
||||
const domainHost = Array.isArray(domain.host) ? domain.host : [domain.host];
|
||||
const currentHost = window.location.host;
|
||||
return id === (domainHost && domainHost.includes(currentHost) ? domainId : 'system') ? '' : `/d/${id}`;
|
||||
}, [domainId, domain]);
|
||||
|
||||
return useCallback((name: string, params: UrlParams = {}, searchParams: Record<string, string> = {}): string => {
|
||||
const pattern = routeMap[name];
|
||||
if (!pattern) {
|
||||
console.warn(`[Hydro] Unknown route: ${name}`);
|
||||
return '#';
|
||||
}
|
||||
|
||||
const { domainId: paramDomainId, ...routeParams } = params;
|
||||
|
||||
try {
|
||||
const prefix = getPrefix(paramDomainId);
|
||||
const path = compile(pattern)(routeParams);
|
||||
const search = new URLSearchParams(searchParams).toString();
|
||||
if (prefix) return `${prefix}${path}${search ? `?${search}` : ''}`;
|
||||
return `${path}${search ? `?${search}` : ''}`;
|
||||
} catch (e) {
|
||||
console.error(`[Hydro] Failed to build URL for route "${name}":`, e);
|
||||
return '#';
|
||||
}
|
||||
}, [routeMap, getPrefix]);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { compile } from 'path-to-regexp';
|
||||
import { useCallback } from 'react';
|
||||
import { useRouteMap } from './use-route-map';
|
||||
|
||||
function buildUrl(pattern: string, params: Record<string, string> = {}): string {
|
||||
try {
|
||||
return compile(pattern)(params);
|
||||
} catch (err) {
|
||||
console.warn(`[Hydro] Failed to build URL for pattern "${pattern}":`, err);
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
|
||||
export function useUrl() {
|
||||
const routeMap = useRouteMap();
|
||||
|
||||
return useCallback((name: string, params: Record<string, string> = {}): string => {
|
||||
const pattern = routeMap[name];
|
||||
if (!pattern) {
|
||||
console.warn(`[Hydro] Unknown route: ${name}`);
|
||||
return '#';
|
||||
}
|
||||
return buildUrl(pattern, params);
|
||||
}, [routeMap]);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import './pages';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import * as api from './api';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Link } from '../components/link';
|
||||
|
||||
export default function Homepage() {
|
||||
return (
|
||||
<div>
|
||||
<div>homepage</div>
|
||||
<Link to="problem_main">problem_main</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
<div>layout test</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerPage } from '../registry/page';
|
||||
|
||||
registerPage('homepage', () => import('./homepage'));
|
||||
registerPage('problem_main', () => import('./problem_main'));
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Link } from '../components/link';
|
||||
|
||||
export default function ProblemMain() {
|
||||
return (
|
||||
<div>
|
||||
<div>problem_main</div>
|
||||
<Link to="homepage">homepage</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { SlotErrorBoundary } from './error-boundary';
|
||||
export { after, before, intercept, patch, replace, wrap } from './interceptors';
|
||||
export { registerPage } from './page';
|
||||
export { installPlugin } from './plugin';
|
||||
export type { PluginAPI, PluginDefinition } from './plugin';
|
||||
export { defineSlot } from './slot';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { lazy } from 'react';
|
||||
import Layout from '../components/layout';
|
||||
import { store } from './store';
|
||||
|
||||
interface PageModule<P = any> {
|
||||
default: React.ComponentType<P>;
|
||||
Layout?: React.ComponentType<React.PropsWithChildren>;
|
||||
}
|
||||
|
||||
type PageLoader<P = any> = () => Promise<PageModule<P>>;
|
||||
|
||||
export function registerPage<P = any>(name: string, loader: PageLoader<P>) {
|
||||
store.setDefault(`page:${name}`, lazy(() =>
|
||||
loader().then((mod) => {
|
||||
const PageComp = mod.default;
|
||||
const LayoutComp = mod.Layout || Layout;
|
||||
return {
|
||||
default(props: React.PropsWithChildren<P>) {
|
||||
return (
|
||||
<LayoutComp>
|
||||
<PageComp {...props} />
|
||||
</LayoutComp>
|
||||
);
|
||||
},
|
||||
};
|
||||
}),
|
||||
));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { after, before, intercept, patch, replace, wrap } from './interceptors';
|
||||
import { registerPage } from './page';
|
||||
|
||||
export interface PluginAPI {
|
||||
intercept: typeof intercept;
|
||||
@@ -7,10 +8,11 @@ export interface PluginAPI {
|
||||
patch: typeof patch;
|
||||
replace: typeof replace;
|
||||
wrap: typeof wrap;
|
||||
registerPage: typeof registerPage;
|
||||
}
|
||||
|
||||
export function createPluginAPI(): PluginAPI {
|
||||
return { intercept, before, after, patch, replace, wrap };
|
||||
return { intercept, before, after, patch, replace, wrap, registerPage };
|
||||
}
|
||||
|
||||
export interface PluginDefinition {
|
||||
|
||||
@@ -74,6 +74,7 @@ function createRegistryStore() {
|
||||
|
||||
function setDefault(name: SlotName, comp: React.FC<any>) {
|
||||
state.defaults[name] = comp;
|
||||
bumpVersion(name);
|
||||
}
|
||||
|
||||
function getDefault(name: SlotName): React.FC<any> | undefined {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type Scope = 'page' | 'component' | 'layout' | string;
|
||||
type Scope = 'app' | 'page' | 'component' | string;
|
||||
export type SlotName = `${Scope}:${string}`;
|
||||
|
||||
export type Interceptor<P = any> = (
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import path from 'path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
base: '/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
publicDir: 'pub',
|
||||
build: {
|
||||
outDir: 'public',
|
||||
|
||||
Reference in new issue
Block a user