diff --git a/.gitignore b/.gitignore index 0a908c760..c6b38a96c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/build/prepare.js b/build/prepare.js index f0bc79eb7..4cefc8878 100644 --- a/build/prepare.js +++ b/build/prepare.js @@ -159,9 +159,6 @@ const UINextConfig = { '@/*': [ './packages/ui-next/src/*', ], - 'vj/*': [ - './packages/ui-default/*', - ], }, }, }; diff --git a/examples/plugins/.gitkeep b/examples/plugins/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/plugins/ui-next-plugin/package.json b/examples/plugins/ui-next-plugin/package.json new file mode 100644 index 000000000..7062530fb --- /dev/null +++ b/examples/plugins/ui-next-plugin/package.json @@ -0,0 +1,12 @@ +{ + "name": "@hydrooj/ui-next-plugin", + "version": "0.0.0", + "author": "Baoshuo ", + "license": "AGPL-3.0", + "hydro": { + "cli": false + }, + "dependencies": { + "@hydrooj/ui-next": "workspace:*" + } +} diff --git a/examples/plugins/ui-next-plugin/ui/index.tsx b/examples/plugins/ui-next-plugin/ui/index.tsx new file mode 100644 index 000000000..f14312624 --- /dev/null +++ b/examples/plugins/ui-next-plugin/ui/index.tsx @@ -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
before app via @hydrooj/ui-next-plugin-sample
; + }); +} diff --git a/package.json b/package.json index 2c1562576..66778edeb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "packages/*", "framework/*", "plugins/*", - "modules/*" + "modules/*", + "examples/plugins/*" ], "main": "package.json", "scripts": { diff --git a/packages/ui-next-plugin-sample/package.json b/packages/ui-next-plugin-sample/package.json deleted file mode 100644 index 31905afb1..000000000 --- a/packages/ui-next-plugin-sample/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@hydrooj/ui-next-plugin-sample", - "dependencies": { - "@hydrooj/ui-next": "workspace:*" - } -} diff --git a/packages/ui-next-plugin-sample/ui/before.tsx b/packages/ui-next-plugin-sample/ui/before.tsx deleted file mode 100644 index a60ec9ea7..000000000 --- a/packages/ui-next-plugin-sample/ui/before.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export default function BeforeComponent() { - console.log('before app'); - // throw new Error('test error boundary in before interceptor'); - return
before app via @hydrooj/ui-next-plugin-sample
; -} diff --git a/packages/ui-next-plugin-sample/ui/index.ts b/packages/ui-next-plugin-sample/ui/index.ts deleted file mode 100644 index 9962f2534..000000000 --- a/packages/ui-next-plugin-sample/ui/index.ts +++ /dev/null @@ -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); -} diff --git a/packages/ui-next/index.ts b/packages/ui-next/index.ts index 0e604d241..08f229873 100644 --- a/packages/ui-next/index.ts +++ b/packages/ui-next/index.ts @@ -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 = ` const INJECT_MARKER = ''; const buildInject = (data: string) => ``; +function getAddonEntries(): Record { + const entries: Record = {}; + 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, diff --git a/packages/ui-next/src/api.ts b/packages/ui-next/src/api.ts index 3c6799e8a..aebd0dab6 100644 --- a/packages/ui-next/src/api.ts +++ b/packages/ui-next/src/api.ts @@ -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 { diff --git a/packages/ui-next/src/app.tsx b/packages/ui-next/src/app.tsx index 2257bac68..6f2f65376 100644 --- a/packages/ui-next/src/app.tsx +++ b/packages/ui-next/src/app.tsx @@ -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 ( +
+ Page not found: {name} +
+ ); + } return ( - <> -
app, page:{data.name}
-
- homepage problem_main -
- + + Loading...}> + + + ); }); -export default AppInner; +export default App; diff --git a/packages/ui-next/src/components/layout.tsx b/packages/ui-next/src/components/layout.tsx new file mode 100644 index 000000000..f3044a719 --- /dev/null +++ b/packages/ui-next/src/components/layout.tsx @@ -0,0 +1,5 @@ +import { defineSlot } from '../registry'; + +const Layout = defineSlot('app:layout', ({ children }: React.PropsWithChildren) => <>{children}); + +export default Layout; diff --git a/packages/ui-next/src/components/link.tsx b/packages/ui-next/src/components/link.tsx index 7f4b5ff4f..3ab30dc4a 100644 --- a/packages/ui-next/src/components/link.tsx +++ b/packages/ui-next/src/components/link.tsx @@ -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, 'href'> { /** Pre-built href. Use this or `to`, not both. */ @@ -8,15 +8,20 @@ export interface LinkProps extends Omit; + 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> = ({ href, to, params, onClick, target, download = false, children, ...rest }) => { - const buildUrl = useUrl(); +export const Link: React.FC> = ({ + 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]); diff --git a/packages/ui-next/src/context/page-data.tsx b/packages/ui-next/src/context/page-data.tsx index 54d9d07bd..389214096 100644 --- a/packages/ui-next/src/context/page-data.tsx +++ b/packages/ui-next/src/context/page-data.tsx @@ -4,7 +4,11 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from 're export interface PageData { name: string; - args: Record; + args: { + UserContext: Record; + UiContext: Record; + [key: string]: any; + }; url: string; } @@ -40,3 +44,11 @@ export function usePageData(): PageData { export function useSetPageData(): React.Dispatch> { 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; +} diff --git a/packages/ui-next/src/globals.ts b/packages/ui-next/src/globals.ts index 8a45b3ab5..32161837f 100644 --- a/packages/ui-next/src/globals.ts +++ b/packages/ui-next/src/globals.ts @@ -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; _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) || {}, + args: (injectionData.args as any) || {}, url: (injectionData.url as string) || (window.location.pathname + window.location.search), }; diff --git a/packages/ui-next/src/hooks/use-build-url.ts b/packages/ui-next/src/hooks/use-build-url.ts new file mode 100644 index 000000000..5b0ae15aa --- /dev/null +++ b/packages/ui-next/src/hooks/use-build-url.ts @@ -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 => { + 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]); +} diff --git a/packages/ui-next/src/hooks/use-url.ts b/packages/ui-next/src/hooks/use-url.ts deleted file mode 100644 index 795bff72c..000000000 --- a/packages/ui-next/src/hooks/use-url.ts +++ /dev/null @@ -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 { - 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 => { - const pattern = routeMap[name]; - if (!pattern) { - console.warn(`[Hydro] Unknown route: ${name}`); - return '#'; - } - return buildUrl(pattern, params); - }, [routeMap]); -} diff --git a/packages/ui-next/src/main.tsx b/packages/ui-next/src/main.tsx index 28ee364e3..29d8eb545 100644 --- a/packages/ui-next/src/main.tsx +++ b/packages/ui-next/src/main.tsx @@ -1,3 +1,5 @@ +import './pages'; + import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import * as api from './api'; diff --git a/packages/ui-next/src/pages/homepage.tsx b/packages/ui-next/src/pages/homepage.tsx new file mode 100644 index 000000000..6282ca7f4 --- /dev/null +++ b/packages/ui-next/src/pages/homepage.tsx @@ -0,0 +1,19 @@ +import { Link } from '../components/link'; + +export default function Homepage() { + return ( +
+
homepage
+ problem_main +
+ ); +} + +export function Layout({ children }: React.PropsWithChildren) { + return ( +
+ {children} +
layout test
+
+ ); +} diff --git a/packages/ui-next/src/pages/index.ts b/packages/ui-next/src/pages/index.ts new file mode 100644 index 000000000..62232fe03 --- /dev/null +++ b/packages/ui-next/src/pages/index.ts @@ -0,0 +1,4 @@ +import { registerPage } from '../registry/page'; + +registerPage('homepage', () => import('./homepage')); +registerPage('problem_main', () => import('./problem_main')); diff --git a/packages/ui-next/src/pages/problem_main.tsx b/packages/ui-next/src/pages/problem_main.tsx new file mode 100644 index 000000000..29a2c30fd --- /dev/null +++ b/packages/ui-next/src/pages/problem_main.tsx @@ -0,0 +1,10 @@ +import { Link } from '../components/link'; + +export default function ProblemMain() { + return ( +
+
problem_main
+ homepage +
+ ); +} diff --git a/packages/ui-next/src/registry/index.ts b/packages/ui-next/src/registry/index.ts index 51d3076a7..30ec23551 100644 --- a/packages/ui-next/src/registry/index.ts +++ b/packages/ui-next/src/registry/index.ts @@ -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'; diff --git a/packages/ui-next/src/registry/page.tsx b/packages/ui-next/src/registry/page.tsx new file mode 100644 index 000000000..bf4f87c00 --- /dev/null +++ b/packages/ui-next/src/registry/page.tsx @@ -0,0 +1,28 @@ +import React, { lazy } from 'react'; +import Layout from '../components/layout'; +import { store } from './store'; + +interface PageModule

{ + default: React.ComponentType

; + Layout?: React.ComponentType; +} + +type PageLoader

= () => Promise>; + +export function registerPage

(name: string, loader: PageLoader

) { + store.setDefault(`page:${name}`, lazy(() => + loader().then((mod) => { + const PageComp = mod.default; + const LayoutComp = mod.Layout || Layout; + return { + default(props: React.PropsWithChildren

) { + return ( + + + + ); + }, + }; + }), + )); +} diff --git a/packages/ui-next/src/registry/plugin.ts b/packages/ui-next/src/registry/plugin.ts index 3f0b585c1..dc63f94ff 100644 --- a/packages/ui-next/src/registry/plugin.ts +++ b/packages/ui-next/src/registry/plugin.ts @@ -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 { diff --git a/packages/ui-next/src/registry/store.ts b/packages/ui-next/src/registry/store.ts index 5838af9b7..93ef08f77 100644 --- a/packages/ui-next/src/registry/store.ts +++ b/packages/ui-next/src/registry/store.ts @@ -74,6 +74,7 @@ function createRegistryStore() { function setDefault(name: SlotName, comp: React.FC) { state.defaults[name] = comp; + bumpVersion(name); } function getDefault(name: SlotName): React.FC | undefined { diff --git a/packages/ui-next/src/registry/types.ts b/packages/ui-next/src/registry/types.ts index 4913fff37..5e71c8a85 100644 --- a/packages/ui-next/src/registry/types.ts +++ b/packages/ui-next/src/registry/types.ts @@ -1,4 +1,4 @@ -type Scope = 'page' | 'component' | 'layout' | string; +type Scope = 'app' | 'page' | 'component' | string; export type SlotName = `${Scope}:${string}`; export type Interceptor

= ( diff --git a/packages/ui-next/vite.config.ts b/packages/ui-next/vite.config.ts index 769d4e522..7e32f6ae8 100644 --- a/packages/ui-next/vite.config.ts +++ b/packages/ui-next/vite.config.ts @@ -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',