luci-theme-footstrap: remove unused plugin and search api

Removes the companion-package extension API: the
footstrap.settings.plugin loader (window.__fsPlugins), fs-search
addSource/refresh and window.__fsSearchSources, and the remember and
nodeForSegs exports. Nothing in the tree used them.

Signed-off-by: Ivan Kvashonkin <vizzlef@gmail.com>
This commit is contained in:
Ivan Kvashonkin authored and Hannu Nyman committed 2026-10-10 16:02:37 +03:00
1 parent a7dc58d459
commit d3de328368
6 files changed
+19 -132

No files matched your search

@@ -159,10 +159,6 @@ function currentNode() {
return baseclass.extend({
setTree,
tree: () => _tree,
/* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu
* node whose `depends.acl` names the group that command needs, and a node that resolves
* elsewhere would answer for a permission the session may not hold */
nodeForSegs,
segsFromPath,
currentNode,
resolveSegs,
@@ -952,8 +952,8 @@ function navigate(pathname, push, kbd) {
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
_lastHovered = null;
/* Run every registered navigation callback. The seam is inverted on purpose: a registrant calls
* onNavigate() and the router names nobody, so an optional module that is not installed cannot
* be a DependencyError taking out the whole chrome.
* onNavigate() and the router names nobody, so the router keeps no static dependency
* (a DependencyError would take out the whole chrome).
*
* The RESOLVED segments are passed in, because this runs before L.env is re-pointed below and a
* callback reading L.env.dispatchpath would record the page being left.
@@ -1452,8 +1452,7 @@ function wireVisibility() {
/* Callbacks to run on every SPA navigation, each handed the resolved segments of the INCOMING page
* (they run before L.env is re-pointed). The registry is inverted on purpose: a registrant calls in
* and the router names nobody, so it cannot grow a static dependency on a module that may not be
* installed. */
* and the router names nobody, so it cannot grow a static dependency on one. */
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
@@ -99,48 +99,6 @@ function index() {
return _index;
}
/* ---- extra sources -------------------------------------------------------
*
* An optional package can add rows to the same list — one indexes the SECTION titles inside each
* page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape
* buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here,
* or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`,
* called when the row is chosen, and `key`, what the recents list stores it under when its `path`
* is not its own (see keyOf).
*
* Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the
* first gesture and most sessions never make it; a package that had to `require` it to register
* would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function
* onto `window.__fsSearchSources` costs the package nothing and names no one in either direction.
*
* `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session
* — and the stamp below is what rebuilds the pool when it does. */
const _sources = [];
let _pool = null, _stamp = -1;
function globalSources() {
return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : [];
}
function addSource(fn) {
_sources.push(fn);
_pool = null;
}
function refresh() {
_pool = null;
}
function pool() {
const all = _sources.concat(globalSources());
const stamp = all.length + (window.__fsSearchGen || 0);
if (_pool && stamp === _stamp) return _pool;
_stamp = stamp;
_pool = all.reduce((rows, fn) => {
try { return rows.concat(fn() || []); }
catch (e) { console.error('footstrap: a search source threw', e); return rows; }
}, index().slice());
return _pool;
}
/* ---- matching ----------------------------------------------------------- */
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
@@ -160,7 +118,7 @@ function search(q, limit) {
if (!toks.length) return [];
const hits = [];
for (const e of pool()) {
for (const e of index()) {
let sum = 0;
for (const tok of toks) {
const s = tokenScore(e, tok);
@@ -179,31 +137,21 @@ function search(q, limit) {
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
* empty state is its most-used view.
*
* Only the KEY is stored, never the title — the title is resolved through the pool on every
* render, so it follows the UI language and a row whose package went away drops out instead of
* lingering as a dead row. A page's key is its menu path; a row from a source carries its own
* `key`, because a section has no dispatcher node and therefore no path that is only its own —
* the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own
* heading. */
* Only the PATH is stored, never the title — the title is resolved through the index on every
* render, so it follows the UI language and a page an ACL change hides drops out instead of
* lingering as a dead row. RECENT_KEY is the one name menu-footstrap-common.js's remember() also
* writes; the two modules stay decoupled (see there), so the string is the only thing shared. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */
function keyOf(e) {
return e.key || e.path;
}
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
* more, and a palette that only loads when it is opened cannot be what records where the admin has
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
* corruption guard and the Array check; only the "these are keys" filter belongs here. */
function recentEntries() {
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
/* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a
* section key resolved to nothing and the row silently vanished, so taking "Footstrap" left
* only "System" in the list — the page path is all either row carries. */
const byKey = new Map(pool().map((e) => [ keyOf(e), e ]));
return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX);
const byPath = new Map(index().map((e) => [ e.path, e ]));
return recent.map((k) => byPath.get(k)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- the palette -------------------------------------------------------- */
@@ -311,16 +259,8 @@ function build() {
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
]);
/* close before the click reaches the router, which re-renders the chrome underneath;
* no focus return, the user is going elsewhere.
*
* `onTake` is how a row from an extra source finishes the job the href cannot: a
* section row's href can only reach the PAGE, so the source that produced it opens the
* tab and scrolls to the section itself. It fires for a click and for Enter alike —
* Enter synthesises this very click. */
a.addEventListener('click', () => {
close(false);
if (typeof e.onTake === 'function') e.onTake();
});
* no focus return, the user is going elsewhere */
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
return a;
@@ -421,7 +361,5 @@ function openPalette() {
}
return baseclass.extend({
open: openPalette,
/* the seam an optional package registers through; see addSource() */
addSource, refresh
open: openPalette
});
@@ -52,27 +52,19 @@ function wirePageModules() {
* and the warm pass that uses it, so both live here, in the file every page already loads.
*
* The palette reads the list back from localStorage when it opens, so the two halves share the key
* and nothing else. */
* and nothing else — fs-search.js owns the "recently visited" feature, so RECENT_KEY and RECENT_MAX
* below have to match its constants of the same name exactly. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
const RECENT_WARM = 5;
/* A key is a menu path, or a page path plus the heading of a section inside it
* (`admin/system/system#Footstrap`) — a section has no dispatcher node to name it, and only the
* source that produced the row can build that half. Exported for exactly that: the writer stays
* one function, or the two halves would drift on the cap and the de-duplication. */
/* A key is a menu path — what the router can navigate to and what warmRecent() prefetches. */
function remember(key) {
if (typeof key !== 'string' || !key) return;
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
prefs.lsSet(RECENT_KEY, JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, RECENT_MAX)));
}
/* the page half of a key: what the router can navigate to and what warmRecent() prefetches */
function pageOf(key) {
const h = key.indexOf('#');
return h < 0 ? key : key.slice(0, h);
}
/* ---- warm the pages this admin actually uses ----
*
* The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to a
@@ -87,10 +79,9 @@ function pageOf(key) {
function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/');
/* Keys, not paths: a section key names the page it sits on, and two sections of one page must
* warm it once — the module chain is the page's. */
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
const keys = prefs.lsGetArr(RECENT_KEY).filter((p) => typeof p === 'string');
const paths = [ ...new Set(keys.map(pageOf)) ].filter((p) => p !== here).slice(0, RECENT_WARM);
const paths = keys.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
@@ -139,23 +130,6 @@ function wireSearch() {
});
}
/* ---- optional companion packages ----
*
* header.ut prints `window.__fsPlugins` from `footstrap.settings.plugin`, a list a package writes
* from its own uci-defaults; each entry is a LuCI module name, already whitelisted there. The
* chrome requires each one after everything below is wired — a plugin registers itself through the
* seams the theme exports (`fs-router.onNavigate`, `fs-search.addSource`) and the theme names
* nobody. A plugin that throws costs only itself.
*
* No plugin, no cost: an empty list is the shipped state and this loop does nothing. */
function loadPlugins() {
const RT = window.L;
const names = Array.isArray(window.__fsPlugins) ? window.__fsPlugins : [];
names.forEach((name) => {
RT.require(name).catch((e) => console.error('footstrap: plugin ' + name + ' did not load', e));
});
}
/* warn/danger split for the meter fill (theme/25-progressbar.css): a DECISION, not a measurement —
* 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading, and a
* plain percentage is kept anyway so every meter on the page reads the same way. */
@@ -382,10 +356,6 @@ ensureOverviewHelpers();
* halves (fs-menutree, fs-prefs) are separate modules. */
return baseclass.extend({
/* the seam a companion package writes its own rows into the recents list through; see
* remember() for what a key is */
remember,
/* the seam fs-overview.js calls, on its own already-coalesced poll-tick observer, to annotate
* the meters a stock Status -> Overview include draws with its own local progressbar() */
annotateMeters,
@@ -422,9 +392,6 @@ return baseclass.extend({
wirePageModules();
router.wire();
router.wireVisibility();
/* last: a plugin registers against the parts above, and a broken one must not be able
* to take the chrome with it */
loadPlugins();
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
* tab together — so this fails loudly rather than silently */
}).catch((e) => console.error('footstrap: chrome init failed', e));
@@ -69,22 +69,10 @@
it, so the CI gate cannot see the difference. */
fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || '';
/* Optional companion packages announce themselves in the theme's own config —
`uci add_list footstrap.settings.plugin=<module>` from the package's uci-defaults — rather
than being named in this tree. The chrome requires each name and knows nothing else about it,
so the edge points inwards exactly as fs-router.onNavigate does.
Whitelisted to the shape of a LuCI module name before it is printed into a <script>: writing
that file needs root, but a value that reaches an inline script is checked anyway. */
let fs_plugins = fsd.plugin ?? [];
if (type(fs_plugins) != 'array')
fs_plugins = [ fs_plugins ];
fs_plugins = filter(fs_plugins, (m) => type(m) == 'string' && match(m, /^[a-zA-Z0-9._-]{1,48}$/));
http.prepare_content('text/html; charset=UTF-8');
-%}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults, fs_plugins }) %}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults }) %}
{#
data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route
@@ -150,7 +150,6 @@
{# The router-wide Appearance defaults, read by both the pre-paint below and fs-prefs.js's
current*(), so the Appearance controls show the effective default when this browser has no
localStorage. See the sanitiser above for the unset encodings. #}
<script>window.__fsPlugins={{ sprintf('%J', fs_plugins) }};</script>
<script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}",content_width:{{ _sd_cwidth }}};</script>
<script>
/* Each IIFE below pre-paints one Appearance axis before the first frame, duplicating a