| name | hydro-plugin-frontend |
| description | Guide to frontend plugin development including page registration (NamedPage, AutoloadPage), Nunjucks templates, UI injection, and internationalization (i18n). Build interactive frontend features for Hydro plugins. |
Hydro Plugin Development: Frontend, Templates, UI & Integration
This skill covers the frontend side of Hydro plugins: page registration (addPage), Nunjucks templates, UI injection (injectUI), internationalization (i18n).
1. Frontend Pages (addPage)
Frontend pages use the @hydrooj/ui-default npm package. The page system is built on top of webpack's require.context for auto-discovery within @hydrooj/ui-default itself. External plugins import from @hydrooj/ui-default as a dependency.
Import
import { NamedPage, AutoloadPage, addPage } from '@hydrooj/ui-default';
import { Notification, $, _, React, i18n, request, tpl, delay } from '@hydrooj/ui-default';
Page class API
class Page {
moduleName?: string;
autoload = false;
afterLoading?: (pagename: string, loadPage: (name: string) => Promise<any>) => any;
beforeLoading?: (pagename: string, loadPage: (name: string) => Promise<any>) => any;
constructor(
name: string | string[],
afterLoading?: Callback,
beforeLoading?: Callback,
)
constructor(
name: string | string[],
moduleName: string,
afterLoading?: Callback,
beforeLoading?: Callback,
)
}
NamedPage — runs only on specific pages
import { NamedPage, i18n, request, tpl } from '@hydrooj/ui-default';
addPage(new NamedPage('home_account', (pagename) => {
const $type = $(tpl`<select class="select">
<option value="gravatar">${i18n('Gravatar')}</option>
<option value="upload">${i18n('Upload')}</option>
</select>`);
}));
Page name matching: The current page name is read from document.documentElement.getAttribute('data-page'). This attribute is set server-side by the template system based on the route name.
new NamedPage('problem_detail', callback);
new NamedPage(['problem_detail', 'contest_detail_problem', 'homework_detail_problem'], callback);
AutoloadPage — runs on EVERY page
import { AutoloadPage, Notification, i18n } from '@hydrooj/ui-default';
addPage(new AutoloadPage('notificationPage', (pagename) => {
const message = i18n(`Hint::Page::${pagename}`);
const item = localStorage.getItem(`hint.${message}`);
if (message !== `Hint::Page::${pagename}` && !item) {
Notification.info(message, message.length * 500);
localStorage.setItem(`hint.${message}`, true);
}
}));
Loading sequence
Pages are executed in this order (see hydro.ts):
1. All AutoloadPage beforeLoading hooks
2. NamedPage beforeLoading hooks (for current page)
3. All AutoloadPage afterLoading hooks
4. NamedPage afterLoading hooks (for current page)
Registering pages with addPage()
Plugins must use addPage() to register pages (external plugins are not compiled by @hydrooj/ui-default's webpack, so export default won't work):
import { addPage, NamedPage, AutoloadPage } from '@hydrooj/ui-default';
addPage(new NamedPage('my_page', (pagename) => {
}));
addPage(new AutoloadPage('my_feature', (pagename) => {
}));
addPage(() => { console.log('init'); });
The registration must happen before page initialization (i.e., during script loading, not in an async callback).
beforeLoading / afterLoading
addPage(new NamedPage('problem_detail', {
afterLoading(pagename, loadPage) {
},
beforeLoading(pagename, loadPage) {
},
}));
Available frontend utilities
import {
i18n,
request,
tpl,
delay,
pjax,
loadReactRedux,
rawHtml,
} from '@hydrooj/ui-default';
import {
$, _, React, ReactDOM, redux,
Notification,
Socket,
uploadFiles,
loadMonaco,
addPage,
AutoComplete,
} from '@hydrooj/ui-default';
Global context objects
window.UiContext
window.UserContext
window.Hydro
window.LANGS
2. CSS / Styling
External plugins cannot use .page.styl auto-discovery (that's a built-in @hydrooj/ui-default feature compiled by webpack). Options for plugin styling:
- Inline styles in templates:
<style>
.my-plugin-widget { color: red; }
</style>
<div class="my-plugin-widget">...</div>
- DOM injection in page scripts:
import { NamedPage } from '@hydrooj/ui-default';
new NamedPage('my_page', () => {
const style = document.createElement('style');
style.innerHTML = `.my-widget { color: red; }`;
document.head.append(style);
});
- Styling via Nunjucks templates — put
<style> blocks in your template HTML.
3. Nunjucks Templates (Server-side Rendering)
Plugins can provide Nunjucks templates in a templates/ directory. The TemplateService auto-discovers them during initialization.
Auto-discovery
const p = locateFile(addonPath, ['template', 'templates']);
if (p && isDirectory(p)) {
const files = await getFiles(p);
for (const file of files) {
this.registry[file] = await fs.readFile(path.resolve(p, file), 'utf-8');
}
}
Template inheritance
{% extends "layout/basic.html" %}
{% block content %}
<div class="section">
<h1>{{ ddoc.title }}</h1>
</div>
{% endblock %}
Common base templates:
layout/basic.html — Standard page layout with nav, sidebar, footer
layout/server_rendered_markdown.html — Markdown content page
Template imports
{% import "components/nothing.html" as nothing with context %}
{% import "components/paginator.html" as paginator with context %}
{% import "components/user.html" as user with context %}
Built-in template variables & functions
Available in all templates:
| Variable | Description |
|---|
_() | i18n translate function |
handler | Current handler instance |
ctx | Current Cordis context |
page_name | Current page/route name |
UserContext | Current user context object |
UiContext | UI context from handler |
PERM | Permission constants |
PRIV | Privilege constants |
STATUS | Status constants |
| Function/Filter | Description |
|---|
url(name, args) | Generate URL from route name |
datetimeSpan(date, relative?, format?) | Render datetime |
paginate(page, count) | Pagination helper |
|json | JSON stringify |
|markdown | Render markdown to HTML |
|ansi | ANSI to HTML |
|content(language, html) | Render localized content |
|base64_encode / |base64_decode | Base64 |
|assign(data) | Merge into object |
set(obj, key, val) | Set value in template |
model | Access Hydro models |
ui | Access Hydro.ui (injectUI nodes) |
templateExists(name) | Check if template exists |
findSubModule(prefix) | Find sub-module templates |
Using handler data in templates
this.response.template = 'blog_detail.html';
this.response.body = {
ddoc: this.ddoc,
udoc,
dsdoc,
};
<h1>{{ ddoc.title }}</h1>
<p>{{ _('By {0}').format(udoc.uname) }}</p>
<span>{{ datetimeSpan(ddoc.updateAt)|safe }}</span>
<a href="{{ url('blog_edit', { uid: udoc._id, did: ddoc._id }) }}">{{ _('Edit') }}</a>
Template override per domain
Templates support domain-specific overrides:
h.renderHTML = ((orig) => function(name, args) {
let templateName = `${s[0]}.${args.domainId}.${s[1]}`;
if (!that.registry[templateName]) templateName = name;
return orig(templateName, args);
})(h.renderHTML);
4. ctx.injectUI() — UI Element Injection
Adds UI elements to predefined injection points in the frontend.
Injection points
| Position | Description | Typical use |
|---|
Nav | Top navigation bar | New section links |
ProblemAdd | Problem creation page | Import buttons |
Notification | System notifications | Warning/info banners |
UserDropdown | User dropdown menu | User-specific links |
DomainManage | Domain management sidebar | Admin sections |
ControlPanel | System control panel | System admin tools |
Registration
ctx.injectUI('ProblemAdd', 'my_import', {
icon: 'copy',
text: 'From My Source',
});
ctx.injectUI('UserDropdown', 'blog_main',
(handler) => ({
icon: 'book',
displayName: 'Blog',
uid: handler.user._id.toString(),
}),
PRIV.PRIV_USER_PROFILE,
);
ctx.injectUI('Nav', 'my_section', {
prefix: 'my_section',
icon: 'star',
text: 'My Section',
}, PERM.PERM_VIEW_PROBLEM);
ctx.injectUI('Nav', 'my_item', {
prefix: 'my_item',
before: 'problem_main',
}, PERM.PERM_VIEW_PROBLEM);
Permission checking in injectUI
The 4th+ arguments are permission/privilege checks:
PERM.xxx (bigint) — domain-level permission
PRIV.xxx (number) — system-level privilege
(handler) => boolean — custom checker function
The checker runs client-side when rendering the UI. If the checker returns false, the item is hidden.
5. Internationalization (ctx.i18n)
Backend i18n
ctx.i18n.load('zh', {
"{0}'s blog": '{0} 的博客',
Blog: '博客',
blog_detail: '博客详情',
'Create a Post': '创建文章',
});
ctx.i18n.load('en', {
blog_main: 'Blog',
blog_detail: 'Blog Detail',
});
ctx.i18n.load('zh_TW', {
Blog: '部落格',
});
Using translations
In templates:
{{ _('Create a Post') }}
{{ _('{0} views').format(count) }}
In handlers:
this.translate('key');
In frontend pages:
i18n('Blog');
i18n('{0} views', count);
Translation lookup
- Exact match:
'Blog' → looks up 'Blog' key
- Fallback chain:
zh_TW → zh → en → key itself
- Frontend uses
window.LANGS dictionary loaded server-side