Skip to content

Commit f9cd5bc

Browse files
authored
ui-next: refactor plugin loader (#1190)
1 parent 0e688f7 commit f9cd5bc

12 files changed

Lines changed: 181 additions & 17 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Context, Handler } from 'hydrooj';
2+
3+
// Server side of the example plugin. It registers a couple of routes whose
4+
// handlers set a `template`; with @hydrooj/ui-next installed the `next` renderer
5+
// (priority 100) wins for every template, ignores the template file, and injects
6+
// the route/template name into the SPA shell. The client then renders the page
7+
// registered under `page:<template without .html>` (falling back to `page:<route name>`).
8+
9+
class ExampleHomeHandler extends Handler {
10+
async get() {
11+
// Stem (`example_home`) must match the slot registered in ui/index.tsx.
12+
this.response.template = 'example_home.html';
13+
// response.body is forwarded to the page as `args` (see usePageData).
14+
this.response.body = { message: 'Hello from the ui-next example plugin server side!' };
15+
}
16+
}
17+
18+
class ExampleCssHandler extends Handler {
19+
async get() {
20+
this.response.template = 'example_css.html';
21+
this.response.body = {};
22+
}
23+
}
24+
25+
export async function apply(ctx: Context) {
26+
// A couple of test routes to host the example pages.
27+
ctx.Route('example_home', '/example', ExampleHomeHandler);
28+
ctx.Route('example_css', '/example/css', ExampleCssHandler);
29+
}

examples/plugins/ui-next-plugin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"cli": false
88
},
99
"dependencies": {
10-
"@hydrooj/ui-next": "workspace:*"
10+
"@hydrooj/ui-next": "workspace:*",
11+
"hydrooj": "workspace:*"
1112
}
1213
}
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { PluginAPI } from '@hydrooj/ui-next';
22

33
export function setup(api: PluginAPI) {
4-
api.before('page:app', () => {
5-
console.log('before app');
6-
// throw new Error('test error boundary in before interceptor');
7-
return <div>before app via @hydrooj/ui-next-plugin-sample</div>;
8-
});
4+
// (2) Page registration — slot names match the server route names / template stems
5+
// registered in ../index.ts. Pages are lazy-loaded on demand.
6+
api.registerPage('example_home', () => import('./pages/example_home'));
7+
api.registerPage('example_css', () => import('./pages/example_css'));
98
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Link } from '@hydrooj/ui-next';
2+
import styles from '../styles.module.css'; // (1) CSS module — scoped class names
3+
4+
export default function ExampleCss() {
5+
return (
6+
<div className={styles.card}>
7+
<span className={styles.badge}>CSS module</span>
8+
<h2 className={styles.title}>Scoped class names</h2>
9+
<p><Link to="example_home">← Back</Link></p>
10+
</div>
11+
);
12+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import '../styles.css'; // (3) plain CSS import — applied globally when this page loads
2+
3+
import { Link, usePageData } from '@hydrooj/ui-next';
4+
5+
export default function ExampleHome() {
6+
const { args } = usePageData(); // args carries the handler's response.body
7+
return (
8+
<div className="example-page">
9+
<h1>ui-next example plugin</h1>
10+
<p>{args.message as string}</p>
11+
<Link to="example_css">See the CSS-module demo →</Link>
12+
</div>
13+
);
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* Imported via `import './styles.css'` — bundled and injected globally as a <style>. */
2+
.example-page {
3+
padding: 24px;
4+
max-width: 720px;
5+
margin: 0 auto;
6+
}
7+
.example-page h1 {
8+
font-size: 24px;
9+
font-weight: 700;
10+
}
11+
.example-page a {
12+
color: #4f46e5;
13+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/* Imported via `import styles from './styles.module.css'`; class names are hashed/scoped,
2+
accessed as styles.card / styles.badge / styles.title. */
3+
.card {
4+
padding: 16px;
5+
border: 1px solid #e5e7eb;
6+
border-radius: 8px;
7+
max-width: 720px;
8+
margin: 24px auto;
9+
}
10+
.badge {
11+
display: inline-block;
12+
padding: 2px 8px;
13+
border-radius: 4px;
14+
background: #4f46e5;
15+
color: #fff;
16+
font-size: 12px;
17+
}
18+
.title {
19+
margin-top: 8px;
20+
font-size: 20px;
21+
font-weight: 700;
22+
}

packages/ui-next/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
/// <reference path="./css.d.ts" />
2+
13
export * from './src/api';

packages/ui-next/css.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
declare module '*.css';
2+
3+
declare module '*.module.css' {
4+
const classes: Record<string, string>;
5+
export default classes;
6+
}

packages/ui-next/index.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import crypto from 'crypto';
21
import fs from 'fs';
32
import path from 'path';
43
import esbuild from 'esbuild';
@@ -7,7 +6,7 @@ import { createServer, type Plugin } from 'vite';
76
import { serializer } from '@hydrooj/framework';
87
import {
98
Context, Handler, Logger,
10-
NotFoundError, param, size, Types,
9+
NotFoundError, param, sha1, size, Types,
1110
} from 'hydrooj';
1211

1312
const logger = new Logger('ui-next');
@@ -100,12 +99,25 @@ const federationPlugin: esbuild.Plugin = {
10099
const vfs: Record<string, string> = {};
101100
const hashes: Record<string, string> = {};
102101

102+
const applyCss = (css: string) => `
103+
(() => {
104+
const style = document.createElement('style');
105+
style.textContent = ${JSON.stringify(css)};
106+
document.head.appendChild(style);
107+
})();
108+
`;
109+
110+
function addFile(name: string, content: string) {
111+
vfs[name] = content;
112+
hashes[name] = sha1(content).substring(0, 8);
113+
}
114+
103115
class UiNextConstantHandler extends Handler {
104116
noCheckPermView = true;
105117

106118
@param('name', Types.Filename)
107119
async all(domainId: string, name: string) {
108-
if (!vfs[name]) throw new NotFoundError(name);
120+
if (!(name in vfs)) throw new NotFoundError(name);
109121
this.response.type = 'application/javascript';
110122
this.response.body = vfs[name];
111123
this.response.addHeader('ETag', hashes[name]);
@@ -118,9 +130,23 @@ export async function buildPlugins() {
118130
let totalSize = 0;
119131
const entries = getAddonEntries();
120132

133+
const newPluginFiles = new Set<string>();
134+
const emit = (name: string, content: string) => {
135+
addFile(name, content);
136+
newPluginFiles.add(name);
137+
};
138+
const purge = () => {
139+
for (const key of Object.keys(vfs)) {
140+
if (!newPluginFiles.has(key)) {
141+
delete vfs[key];
142+
delete hashes[key];
143+
}
144+
}
145+
};
146+
121147
if (!Object.keys(entries).length) {
122-
vfs['plugins.js'] = 'window.__hydroPlugins = [];';
123-
hashes['plugins.js'] = '00000000';
148+
emit('plugins.js', 'window.__hydroPlugins = [];');
149+
purge();
124150
logger.info('No plugins to build');
125151
return;
126152
}
@@ -132,11 +158,18 @@ export async function buildPlugins() {
132158
...Object.entries(entries).map(([_, e], i) => `import * as plugin${i} from '${e}';`),
133159
`window.__hydroPlugins = [${Object.entries(entries).map(([n], i) => `{ name: '${n}', ...plugin${i} }`).join(', ')}];`,
134160
].join('\n'),
161+
sourcefile: 'plugins.ts',
135162
resolveDir: process.cwd(),
136163
loader: 'ts',
137164
},
138165
bundle: true,
139-
format: 'iife',
166+
format: 'esm',
167+
splitting: true,
168+
outdir: 'plugins-dist',
169+
entryNames: 'plugins',
170+
chunkNames: 'chunk-[hash]',
171+
assetNames: 'asset-[hash]',
172+
metafile: true,
140173
write: false,
141174
target: ['chrome90'],
142175
plugins: [federationPlugin],
@@ -145,11 +178,42 @@ export async function buildPlugins() {
145178
jsxImportSource: 'react',
146179
});
147180
if (result.errors.length) logger.error('Plugin build errors: %o', result.errors);
148-
const content = result.outputFiles?.[0]?.text || 'window.__hydroPlugins = [];';
149-
vfs['plugins.js'] = content;
150-
hashes['plugins.js'] = crypto.createHash('sha1').update(content).digest('hex').substring(0, 8);
151-
totalSize += content.length;
152-
logger.success('Plugins built in %dms (%d entries, %s)', Date.now() - start, entries.length, size(totalSize));
181+
182+
const cssText = new Map<string, string>();
183+
for (const f of result.outputFiles) {
184+
if (f.path.endsWith('.css')) cssText.set(f.path, f.text);
185+
}
186+
187+
const cssForJs = new Map<string, string>();
188+
const claimed = new Set<string>();
189+
for (const [rel, meta] of Object.entries(result.metafile.outputs)) {
190+
if (!meta.cssBundle) continue;
191+
const css = path.resolve(meta.cssBundle);
192+
cssForJs.set(path.resolve(rel), css);
193+
claimed.add(css);
194+
}
195+
196+
let unclaimedCss = '';
197+
for (const [abs, text] of cssText) {
198+
if (!claimed.has(abs)) unclaimedCss += text;
199+
}
200+
201+
for (const f of result.outputFiles) {
202+
if (f.path.endsWith('.css')) continue;
203+
204+
const name = path.basename(f.path);
205+
let content = f.text;
206+
207+
const css = cssText.get(cssForJs.get(f.path) ?? '');
208+
if (css) content = applyCss(css) + content;
209+
if (name === 'plugins.js' && unclaimedCss) content = applyCss(unclaimedCss) + content;
210+
211+
totalSize += content.length;
212+
emit(name, content);
213+
}
214+
215+
purge();
216+
logger.success('Plugins built in %dms (%d entries, %s)', Date.now() - start, Object.keys(entries).length, size(totalSize));
153217
} catch (e) {
154218
logger.error('Plugin build failed: %o', e);
155219
}

0 commit comments

Comments
 (0)