Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions build/prepare.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,6 @@ const UINextConfig = {
'@/*': [
'./packages/ui-next/src/*',
],
'vj/*': [
'./packages/ui-default/*',
],
},
},
};
Expand Down
Empty file added examples/plugins/.gitkeep
Empty file.
12 changes: 12 additions & 0 deletions examples/plugins/ui-next-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
9 changes: 9 additions & 0 deletions examples/plugins/ui-next-plugin/ui/index.tsx
Original file line number Diff line number Diff line change
@@ -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>;
});
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"packages/*",
"framework/*",
"plugins/*",
"modules/*"
"modules/*",
"examples/plugins/*"
],
"main": "package.json",
"scripts": {
Expand Down
6 changes: 0 additions & 6 deletions packages/ui-next-plugin-sample/package.json

This file was deleted.

5 changes: 0 additions & 5 deletions packages/ui-next-plugin-sample/ui/before.tsx

This file was deleted.

6 changes: 0 additions & 6 deletions packages/ui-next-plugin-sample/ui/index.ts

This file was deleted.

66 changes: 37 additions & 29 deletions packages/ui-next/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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}`;
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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/'];
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-next/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 29 additions & 10 deletions packages/ui-next/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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}`;

return (
<>
<div>app, page:{data.name}</div>
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>
<Link to="homepage">homepage</Link> <Link to="problem_main">problem_main</Link>
Page not found: <code>{name}</code>
</div>
</>
);
}

return (
<SlotErrorBoundary slotName={slotName} label="renderer">
<Suspense fallback={<div>Loading...</div>}>
<Comp />
</Suspense>
</SlotErrorBoundary>
);
});

export default AppInner;
export default App;
5 changes: 5 additions & 0 deletions packages/ui-next/src/components/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineSlot } from '../registry';

const Layout = defineSlot('app:layout', ({ children }: React.PropsWithChildren) => <>{children}</>);

export default Layout;
13 changes: 9 additions & 4 deletions packages/ui-next/src/components/link.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
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. */
href?: string;
/** 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]);
Expand Down
14 changes: 13 additions & 1 deletion packages/ui-next/src/context/page-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
3 changes: 1 addition & 2 deletions packages/ui-next/src/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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),
};
41 changes: 41 additions & 0 deletions packages/ui-next/src/hooks/use-build-url.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading
Loading