ui-next: page registration (#1159)

This commit is contained in:
Baoshuo authored and GitHub committed 2026-05-23 20:27:15 +08:00
1 parent 66a8a0ab9e
commit bbd7fbc852
28 files changed
+238 -98

No files matched your search

+2 -2
View File
@@ -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
-3
View File
@@ -159,9 +159,6 @@ const UINextConfig = {
'@/*': [
'./packages/ui-next/src/*',
],
'vj/*': [
'./packages/ui-default/*',
],
},
},
};
View File
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
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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 {
+30 -11
View File
@@ -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;
+9 -4
View File
@@ -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]);
+13 -1
View File
@@ -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;
}
+1 -2
View File
@@ -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]);
}
-25
View File
@@ -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]);
}
+2
View File
@@ -1,3 +1,5 @@
import './pages';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import * as api from './api';
+19
View File
@@ -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>
);
}
+4
View File
@@ -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
View File
@@ -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';
+28
View File
@@ -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>
);
},
};
}),
));
}
+3 -1
View File
@@ -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 {
+1
View File
@@ -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 -1
View File
@@ -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> = (
+7
View File
@@ -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',