mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-26 22:42:52 +08:00
217 lines
11 KiB
Markdown
217 lines
11 KiB
Markdown
# How LuCI renders a theme
|
|
|
|
What LuCI expects from a theme on OpenWrt 24.10 and 25.12+, and where footstrap plugs into it.
|
|
Read this first if you are new to the codebase.
|
|
|
|
> Verified against `openwrt/luci` at the commit pinned in
|
|
> `luci-theme-footstrap/luci-upstream.pin`, against the `openwrt-24.10` branch, and on a live
|
|
> OpenWrt 25.12 router.
|
|
|
|
## A theme is a server shell plus client assets
|
|
|
|
LuCI runs on ucode, not Lua, since 23.05: dispatcher in `/usr/share/ucode/luci/dispatcher.uc`,
|
|
templates as `*.ut` under `/usr/share/ucode/luci/template/`.
|
|
|
|
**Page content is rendered on the client.** The server sends a shell — header, an empty
|
|
`<div id="view">`, footer — and `luci.js` instantiates the page's view module from
|
|
`/www/luci-static/resources/view/**`.
|
|
|
|
So a theme is **a server shell (header/footer in ucode) + CSS + client JS for the menu**. No Lua,
|
|
no server-side controllers.
|
|
|
|
```
|
|
view.ut
|
|
├─ include('header') → luci-base header.ut
|
|
│ ├─ include(`themes/${theme}/header`) ← the theme's header.ut
|
|
│ └─ <script src="…/luci.js"> + L = new LuCI({…env…})
|
|
├─ <div id="view"> + ui.instantiateView('<view>')
|
|
└─ include('footer') → luci-base footer.ut
|
|
└─ include(`themes/${theme}/footer`) ← the theme's footer.ut
|
|
```
|
|
|
|
The theme is never called directly: luci-base wraps it, and `luci.js` loads *after* the theme's
|
|
header.
|
|
|
|
## Both release lines, one template API
|
|
|
|
**24.10 and 25.12+ share the template API**, checked rather than assumed. 24.10 is already ucode
|
|
(`modules/luci-base/ucode/` exists there) and carries everything the theme uses: `ctx.path`,
|
|
`ctx.request_path`, `entityencode`, `striptags`, `dispatcher.build_url/lookup/lang`, `ubus.call`,
|
|
`pkgs_update_time`. The `L.env` blob luci-base's own `header.ut` prints is **byte-identical**
|
|
between branches, so `L.env.dispatchpath` — which the menu and the SPA router depend on — exists in
|
|
both.
|
|
|
|
**The one difference is the package manager**: apk on 25.12+, opkg/`.ipk` on 24.10. CI builds both
|
|
formats; `install.sh` detects which one the router runs.
|
|
|
|
## How LuCI picks a theme
|
|
|
|
From `runtime.uc`:
|
|
|
|
```ucode
|
|
let media = uci.get('luci', 'main', 'mediaurlbase'); // e.g. '/luci-static/footstrap'
|
|
let status = self.trycompile(`themes/${basename(media)}/header`);
|
|
if (status !== true) {
|
|
// fall back to the first theme in luci.themes whose header compiles
|
|
}
|
|
self.env.media = media; self.env.theme = basename(media);
|
|
```
|
|
|
|
Three things follow:
|
|
|
|
1. The theme name is `basename(mediaurlbase)`, and its templates must live in
|
|
`/usr/share/ucode/luci/template/themes/<name>/header.ut`.
|
|
2. **A header that does not compile does not brick the UI.** LuCI falls back to the first working
|
|
theme and shows a "Theme fallback" indicator carrying the error. Convenient while developing —
|
|
and the reason CI compiles every `.ut` with `ucode -T -c`: a stray brace would otherwise ship
|
|
green and quietly move every user to another theme.
|
|
3. `resource` is always `/luci-static/resources`, shared with luci-base. Themes must not touch it.
|
|
|
|
## What the header must do
|
|
|
|
1. `http.prepare_content('text/html; charset=UTF-8')`.
|
|
2. Emit `<!DOCTYPE html><html><head>…` with its own CSS.
|
|
3. Include `{{ resource }}/cbi.js` and the translations script.
|
|
4. Honour `node.css` and `css` — individual pages ask for extra stylesheets.
|
|
5. Open the content container and leave it **unclosed**; the footer closes it.
|
|
6. Honour `blank_page` — draw no chrome when it is true.
|
|
7. Emit the empty menu containers: `#topmenu`, `#tabmenu`, `#modemenu`, `#indicators`.
|
|
|
|
**`data-page` on `<body>` matters**: luci-base and app CSS target pages through it
|
|
(`[data-page="admin-status-overview"]`).
|
|
|
|
Variables a theme template gets: `theme`, `media`, `resource`, `ctx`, `dispatcher`, `dispatched`,
|
|
`node`, `version`, `config`, `blank_page`, `css`, `lua_active`, `http`, `ubus`.
|
|
|
|
### Where footstrap differs
|
|
|
|
- **One template directory**, `ucode/template/themes/footstrap/`. `header.ut` is thin; `<head>`,
|
|
brand, notices, logout and search live in `partials/*.ut`. Sidebar and top bar are **not a second
|
|
template** — they are `:root[data-layout]` plus CSS.
|
|
- **`data-page` is built from the dispatch path**, `join('-', length(ctx.path) ? ctx.path :
|
|
ctx.request_path)` — the reverse of bootstrap's order. On a firstchild route (`/admin/status`
|
|
renders overview) `request_path` is only `['admin','status']`, so `body[data-page=
|
|
'admin-status-overview']` rules and the overview include silently did not apply.
|
|
- The first element in `<body>` is a skip link; the content container is
|
|
`<main class="fs-main" id="maincontent" tabindex="-1">`; the menu is `<nav class="fs-sidebar">`.
|
|
Not `<aside>` — that carries the `complementary` role, which no landmark jump reaches.
|
|
- The document heading is `<h1 class="fs-title-main">` inside `.fs-title.fs-sr`, **clipped, not
|
|
`hidden`**: `hidden` is `display:none`, which drops the element out of the accessibility tree, so
|
|
the `<h1>` effectively did not exist while the SPA router dutifully updated it.
|
|
- `partials/head.ut` sends `cascade.css`, `node.css` and `cbi.js` with `?v={{ pkgs_update_time }}`
|
|
(uhttpd sends no `Cache-Control`) and passes every
|
|
interpolation through `entityencode(striptags(...))`.
|
|
|
|
## `sysauth.ut` is mandatory for a theme with its own chrome
|
|
|
|
luci-base's generic `sysauth.ut` opens with `{% include('header') %}` — **without `blank_page`** —
|
|
while the theme's header hides its whole shell behind `{% if (!blank_page) %}`. Without a
|
|
theme-local `sysauth.ut`, sidebar, menu and footer draw around the login form and every control in
|
|
them is dead: there is no session yet.
|
|
|
|
The theme's `sysauth.ut` exists for exactly one line:
|
|
|
|
```
|
|
{% include('header', { blank_page: true }) %}
|
|
```
|
|
|
|
**The form is rendered by the SERVER.** Bootstrap's pattern — a hidden `<section>` plus a client
|
|
view module — was tried here and produced a **blank page you could not log in from**: the view
|
|
bootstraps before a session exists, its RPCs answer "Access denied", the promise rejects and
|
|
`render()` never runs. Server rendering works with JS off, cannot be broken by a rejected promise,
|
|
and needs no `luci-theme-bootstrap` installed. Do not go back.
|
|
|
|
Login strings deliberately carry **no `msgctxt`**, so luci-base translates them in the ~40 languages
|
|
this theme has no catalogue for — see [conventions.md](conventions.md).
|
|
|
|
## The menu is entirely client-side
|
|
|
|
The server header emits empty containers by id; client JS fills them. The footer loads the renderer
|
|
with `L.require('menu-footstrap')`, then `L.require('fs-select')`.
|
|
|
|
Menu JS lives in `htdocs/luci-static/resources/` — **not** in the theme directory — because
|
|
`L.require()` resolves against `resourcebase`. From there it pulls its dependencies through
|
|
`'require <module> as <name>'` pragmas:
|
|
|
|
```
|
|
menu-footstrap → ui, fs-fit, fs-prefs, fs-widgets, menu-footstrap-common
|
|
menu-footstrap-common → ui, fs-fit, fs-menutree, fs-chrome, fs-router,
|
|
fs-prefs, fs-sheets, fs-search
|
|
fs-select (no requirer — the footer loads it directly)
|
|
fs-overview (PAGE MODULE — required by menu-footstrap-common when
|
|
body[data-page] says the page is theirs, see below)
|
|
```
|
|
|
|
The graph is acyclic and the runtime enforces it: `require()` throws `DependencyError` on a cycle.
|
|
|
|
**One module is loaded by PAGE, not by pragma.** `fs-overview` reshapes Status → Overview, watches
|
|
`body[data-page]` and does nothing anywhere else, because that page is `luci-mod-status`'s own — a
|
|
theme may register a dispatcher node of its own (`fs-appearance` does, below), but that adds a
|
|
route, it does not hand over one another package already owns. A `'require'` pragma, though, is a
|
|
hard dependency — luci.js evaluates the module before the requirer's factory runs, on every admin
|
|
page — so `menu-footstrap-common.js`
|
|
carries a map from `data-page` to module name and requires the module when that page appears
|
|
(server-stamped or router-stamped, the same attribute either way). The module is unchanged, including
|
|
its own observer: `wire()` re-checks the page synchronously, so one that arrives after the stamp
|
|
still starts watching. The map repeats a page name the module also tests, and `npm run page-modules`
|
|
derives both sides and fails if they drift.
|
|
|
|
`fs-appearance` (the Appearance controls) used to be the map's other entry, stapled onto System →
|
|
System the same way. It now owns a route of its own, `/admin/system/footstrap`
|
|
(`root/usr/share/luci/menu.d/luci-theme-footstrap.json`, dispatched to
|
|
`view/footstrap/appearance.js`), which requires it by pragma like any ordinary view — an ordinary
|
|
dependency, not a page-watching module, because a theme with a route no longer needs the
|
|
`body[data-page]` workaround for the page it owns.
|
|
|
|
**There is no update checker, and nothing that loads one.** The installer adds the owfeed-packages
|
|
feed, so `apk upgrade` / `opkg upgrade` carries the theme forward, and a settings page has no
|
|
business reaching GitHub to reimplement that. `fs-version.js` reports the installed version with no
|
|
request at all. Nothing globs for an optional file at render time either: the wallpaper pattern is an
|
|
upload, so the server hands the client its cache-bust token like any other saved value.
|
|
|
|
Renderer and shell in detail: [chrome.md](chrome.md).
|
|
|
|
## Registration in UCI
|
|
|
|
```
|
|
config core 'main'
|
|
option mediaurlbase '/luci-static/footstrap' # the active theme
|
|
option resourcebase '/luci-static/resources'
|
|
|
|
config internal 'themes'
|
|
option Footstrap '/luci-static/footstrap' # the list the dropdown is built from
|
|
```
|
|
|
|
**Exactly one entry.** Bootstrap registers a symlinked theme per mode (`BootstrapDark`, …);
|
|
footstrap does not, because mode, palette and layout are client axes. Details:
|
|
[package.md](package.md).
|
|
|
|
## The runtime layout on the router
|
|
|
|
`luci.mk` maps `ucode/` → `/usr/share/ucode/luci`, `htdocs/` → `/www`, `root/` → `/`:
|
|
|
|
```
|
|
/usr/share/ucode/luci/template/themes/footstrap/
|
|
header.ut, footer.ut, sysauth.ut, partials/*.ut
|
|
/www/luci-static/footstrap/ cascade.css (generated), fonts/, logo.svg
|
|
(+ pattern.svg — a symlink to /etc/footstrap, uploaded)
|
|
/www/luci-static/resources/ menu-footstrap.js, menu-footstrap-common.js, fs-*.js
|
|
/usr/lib/lua/luci/i18n/footstrap-theme.<lang>.lmo catalogue, bundled INSIDE the theme package
|
|
/usr/share/rpcd/acl.d/luci-theme-footstrap.json ACL: uci footstrap + login-bg upload
|
|
/etc/uci-defaults/30_luci-theme-footstrap registration
|
|
```
|
|
|
|
## Where the boundary runs
|
|
|
|
The theme supplies the **chrome and the design language**. Page content is drawn by the view JS of
|
|
`luci-mod-*`, whose structure the app fixes.
|
|
|
|
The theme **can** style every stock widget through `cascade.css` and its tokens, own the sidebar,
|
|
top bar, logo and indicators, and re-order the stock overview sections.
|
|
|
|
The theme **cannot** turn the overview into a KPI dashboard with a memory ring and sparklines —
|
|
that is a view module in a separate `luci-app-*`.
|
|
|
|
The single exception is `fs-overview.js`: a layout-only, additive include that moves stock overview
|
|
sections (System left, Memory/Storage right) and draws no content of its own.
|