{# for the ONE template. include(…, { boardinfo, fs_defaults }); everything else comes from the render scope. fs_defaults = the ROUTER's saved Appearance defaults (luci-theme-footstrap /etc/config/footstrap), which the client's own localStorage overrides before paint. Licensed to the public under the Apache License 2.0. -#} {% /* Sanitise the router's saved Appearance defaults into the window.__fsSD object the pre-paint below reads. Enums are whitelisted and numbers clamped, so a hand-edited config can neither break the inline script nor inject markup (defence in depth — writing that file needs root). Absent or invalid becomes '' (or -1 where 0 is a real value), which the JS reads as "no router default"; the client's localStorage overrides every one before paint. `fs_defaults` is a required include parameter: header.ut is the only caller and builds it before the include, so a `?? {}` here would only hide that contract from a second caller. */ let _sd_lay = index([ 'sidebar', 'top' ], fs_defaults.layout) >= 0 ? fs_defaults.layout : ''; let _sd_dark = index([ 'auto', 'light', 'dark' ], fs_defaults.darkmode) >= 0 ? fs_defaults.darkmode : ''; let _sd_pal = index([ 'footstrap', 'hicontrast', 'bootstrap', '2020', 'forum' ], fs_defaults.palette) >= 0 ? fs_defaults.palette : ''; let _sd_wall = index([ 'off', 'pattern', 'file' ], fs_defaults.wallpaper) >= 0 ? fs_defaults.wallpaper : ''; let _sd_pink = index([ 'theme', 'original' ], fs_defaults.pattern_ink) >= 0 ? fs_defaults.pattern_ink : ''; let _sd_ac = index([ 'on', 'off' ], fs_defaults.autocollapse) >= 0 ? fs_defaults.autocollapse : ''; let _sd_dens = index([ 'compact', 'normal', 'large' ], fs_defaults.density) >= 0 ? fs_defaults.density : ''; /* issue #44: how far the content column may grow past --fs-content-max's 1280px default. header.ut's FS_AXES carries 'content_width', so a saved router default reaches this. */ let _cw = int(fs_defaults.content_width); let _sd_cwidth = (fs_defaults.content_width != '' && _cw >= 1280 && _cw <= 3840) ? _cw : -1; /* The five colour axes each hold one of three things: '' or '0' for off, '1'..'360' for a hue, or '#rrggbb'. Both live forms reach the client as a string, and the pre-paint re-reads which it is the way fs-prefs.js's normColor() does, so this passes through exactly those two shapes and turns everything else into ''. The hex charset is whitelisted because the value is interpolated into the inline data block below. (Do not write that block's tag name in a comment: tools/lib/ut-scripts.mjs finds the inline scripts by matching the opening tag, and a mention in prose is one it will match.) */ function _sd_color(v) { if (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/)) return lc(v); let n = int(v); return (n >= 1 && n <= 360) ? `${n}` : ''; } let _sd_tint = _sd_color(fs_defaults.tint); let _sd_acc = _sd_color(fs_defaults.accent); let _sd_good = _sd_color(fs_defaults.good); let _sd_warn = _sd_color(fs_defaults.warn); let _sd_dang = _sd_color(fs_defaults.danger); /* the four surface axes (cards, inset controls, the chrome bar, hairlines). Hex or nothing: * they have no hue mode, so _sd_color's number branch would let a bare '0' through as a * value. */ function _sd_hex(v) { return (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/)) ? lc(v) : ''; } let _sd_card = _sd_hex(fs_defaults.card); let _sd_ctrl = _sd_hex(fs_defaults.control); let _sd_bar = _sd_hex(fs_defaults.bar); let _sd_line = _sd_hex(fs_defaults.line); let _rr = int(fs_defaults.rounding); /* rounding 0 is SHARP corners, a real choice, so '' (unset) must map to -1, not 0 */ let _sd_round = (fs_defaults.rounding != '' && _rr >= 0 && _rr <= 20) ? _rr : -1; /* login_bg is the cache-bust TOKEN for the uploaded background (the cgi-upload md5), not the image and not a path — the served path is fixed. Whitelisted to hex so a hand-edited config cannot inject markup into the inline script that builds the url(). */ let _lb = fs_defaults.login_bg; let _sd_lbg = (type(_lb) == 'string' && match(_lb, /^[a-f0-9]{6,64}$/)) ? _lb : ''; /* the Tint's strength 0-200 (% of the designed chroma). Unset -> -1, like rounding, because 0 is a real value. */ let _ts = int(fs_defaults.tint_strength); let _sd_tstr = (fs_defaults.tint_strength != '' && _ts >= 0 && _ts <= 200) ? _ts : -1; /* the File photo's scrim opacity 0-100; unset -> -1, 0 being a real value (a transparent scrim) */ let _pd = int(fs_defaults.photo_dim); let _sd_pdim = (fs_defaults.photo_dim != '' && _pd >= 0 && _pd <= 100) ? _pd : -1; /* the uploaded pattern's cache-bust token, whitelisted like login_bg and for the same reason; the served path is fixed */ let _pt = fs_defaults.pattern; let _sd_pat = (type(_pt) == 'string' && match(_pt, /^[a-f0-9]{6,64}$/)) ? _pt : ''; /* the pattern's tile edge in px and the layer's opacity 0-100; unset -> -1, since 0 is a real strength and 0 px is not a tile */ let _ps = int(fs_defaults.pattern_size); let _sd_psize = (fs_defaults.pattern_size != '' && _ps >= 40 && _ps <= 1600) ? _ps : -1; let _pst = int(fs_defaults.pattern_strength); let _sd_pstr = (fs_defaults.pattern_strength != '' && _pst >= 0 && _pst <= 100) ? _pst : -1; /* ---- the fonts ---- The theme carries no faces, so the two stacks in styles/02-tokens.css are what everyone gets unless the router names something else here. Two independent options, either usable alone: font_sans / font_mono a font-family stack, printed into a style element further down fonts the cache-bust md5 of an @font-face sheet at /etc/footstrap/fonts/fonts.css, for when the admin installs the files too The package is the serving half only: it reads these options, serves whatever is in /etc/footstrap/fonts and keeps that directory across a sysupgrade, installing no faces and fetching nothing at run time. `fonts/set-font.sh` is a repository tool and not part of this package: it names third-party font hosts, which a theme in this tree must not carry. Neither is an axis — no browser layer, no Appearance control, no entry in snapshotAxes(). The charset is most of the defence: a family stack is free text printed UNESCAPED, so the whitelist excludes everything that could end the declaration or the element (< > ( ) { } ; and the backslash), leaving no way to close a style element, start a second declaration or write a url(). A non-Latin family name is refused too; those faces carry a Latin name as well. The two quotes are what the charset cannot refuse, and neither ends anything — they EXTEND. Both must be allowed (the shipped default is `"Manrope",system-ui,…`), and one left open starts a CSS string that runs to the end of the sheet, so a typo in the sans stack silently costs the mono one. Counting quotes is not the test, tokenizing being stateful: inside '…' a " is literal and vice versa, so `"O'Reilly Sans", 'Fira Sans` holds two of each while the second `'` is still open. Scan in order instead: the quote that opens wins until its own kind closes it, and one still open at the end refuses the value. */ function _sd_family(v) { if (!(type(v) == 'string' && length(v) >= 1 && length(v) <= 120 && match(v, /^[A-Za-z0-9 ,._"'-]+$/))) return ''; let q = ''; for (let c in split(v, '')) { if (q == '') q = (c == '"' || c == "'") ? c : ''; else if (c == q) q = ''; } return (q == '') ? v : ''; } let _sd_fsans = _sd_family(fs_defaults.font_sans); let _sd_fmono = _sd_family(fs_defaults.font_mono); let _ft = fs_defaults.fonts; let _sd_ftok = (type(_ft) == 'string' && match(_ft, /^[a-f0-9]{6,64}$/)) ? _ft : ''; /* ---- writing direction ---- Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops, so a router in Arabic, Persian or Hebrew renders right-to-left text inside a left-to-right document — paragraphs align to the wrong edge and every mirrored affordance points the wrong way. The theme owns , so this is the one place that can say it. Matched on the base subtag, so a future `ar-EG` is covered, and the list is the standard RTL set rather than the three LuCI ships catalogues for. `ltr` is written explicitly for the same reason data-layout is: every rule then matches positively. */ let _lang = dispatcher.lang; let _lbase = lc(split(_lang, /[-_]/)[0]); let _rtl = index([ 'ar', 'arc', 'ckb', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ps', 'sd', 'ur', 'yi' ], _lbase) >= 0; %} {# The server always stamps data-layout with an EXPLICIT value (sidebar|top), so every layout rule matches positively and a future third layout must opt in rather than inherit another's rules by not being it — never write a :not([data-layout=…]) guard. The chrome is right with JS off; the script below only overrides it. Absent = TOP: the bar is what the theme is built around (every layout rule starts from it, with the vertical column as the one guarded override) and it is what a phone gets at any setting. #} {# entityencode around striptags: striptags drops tags but escapes neither `&` nor quotes, so a hostname with `&` breaks entity parsing #} {{ entityencode(striptags(`${boardinfo.hostname ?? 'OpenWrt'}${dispatched?.title ? ` | ${_(dispatched.title)}` : ''}`), true) }} {# 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. #} {# The colour a mobile browser paints its own chrome with — address bar, task switcher, the installed app's title bar. Without it a dark page keeps a white bar above it. The literal is the default palette's page colour and the SAME value manifest.json carries, for the same reason: neither file can be rendered per request. It is a starting value only — fs-prefs.js repaints this tag from the live body background, so it follows palette, mode and every tint axis. tests/theme-color.test.mjs holds the two literals equal. One tag, no `media` pair: a media-qualified tag would win over this one for a viewer whose OS is dark but who chose light HERE, and the choice is the theme's to honour. #} {# The sheet link below is emitted only when the TOKEN says the sheet exists — set the option after the file, never before. A preload is not in the stylesheet, so dropping the @font-face rules alone left the pair behind and every page went on asking the router for files that were gone: six 404s per page, measured, and nothing in CSS could say so. #} {# `data-fs-shell` marks the shell's own CSS: on every nav the SPA router asks whether a VIEW put CSS in the document, and this is how it tells its own sheet from an app's. #} {% if (_sd_ftok != ''): %} {# The generated @font-face sheet, and only the faces: the families come from the element below, so setting font_sans by hand takes effect without regenerating anything. The token is the sheet's md5 — uhttpd sends no Cache-Control, so without it a browser applies heuristic freshness and keeps the previous face for days. #} {% endif %} {% if (_sd_fsans != '' || _sd_fmono != ''): %} {# The router's own font stacks. Unlayered, so it beats the `tokens` layer declaring the defaults whatever order the two sheets arrive in. Values are whitelisted at the top of this file; nothing here escapes them. #} {% endif %} {# The manifest lets an admin install this page as an app: an icon, its own window and no address bar, which is worth more than a tab on the phone LuCI is opened from when the network is broken. Both files are static and cannot be otherwise, a theme having no dispatcher node to render them per request. That fixes two values: start_url/scope — `/cgi-bin/luci/`, uhttpd's default and the path this template is served from on a stock install. An admin who moved LuCI gets an installed app that opens the standard path; the browser tab is unaffected. background/theme_color — the default palette's page colour. The manifest is read once at install time and cannot follow a live Appearance change, and it only paints the splash and the window chrome. Chrome offers its install prompt on a secure context only, so over plain HTTP this is iOS's Add to Home Screen plus the icons. Nothing here is on the critical path. `.json`, not the conventional `.webmanifest`: uhttpd's built-in MIME map has no entry for that extension, so the same bytes come back `application/octet-stream` there and with a JSON type only as `.json`. The map is compiled into uhttpd. #} {# iOS reads this link and never the manifest's icons array, and it scales the 192 square the manifest also uses down to its own 180. One raster serves both — tools/build-icons.mjs. #} {% if (dispatched?.css): %} {# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server should link for its page, instead of the view injecting one at module eval. `dispatched`, not `node`: the dispatcher exports the resolved node as `dispatched` and never binds a `node` — every ucode theme carried that typo until openwrt/luci#8920, so the link had never once been emitted. It also requires `css: 'string'` in the dispatcher's node schema, added by the same commit; without it the property is filtered out of the tree and this stays false, as on 24.10 and 25.12. uhttpd sends no Cache-Control, so without a version key a stale app stylesheet can be served for days after an upgrade. Deliberately not `data-fs-shell`: this sheet belongs to one page, so the SPA router must judge and contain it like any other app sheet. #} {% endif %} {% if (css): %} {# no `title` attribute: on a {% endif %} {# The catalogue is a CGI spawn on the critical path — 30 ms on the router — and at lang=en it spends them to deliver 13 bytes, there being no en catalogue because the msgids already are English. The process is the cost, not the data, so emit those bytes inline when this language has no catalogue and keep the tag when it has. The probe mirrors the server: determine_request_language() loads the catalogue with load_catalog(lang, '/usr/lib/lua/luci/i18n'), i.e. every `*..lmo` in that directory, so a router that does ship an en catalogue still gets the tag. Deciding by language NAME would silently drop it. Fails open — a throwing probe keeps the tag — because a missing catalogue makes every `_()` render English and report nothing. #} {%- let has_catalog = true; try { has_catalog = length(require('fs').glob(`/usr/lib/lua/luci/i18n/*.${dispatcher.lang}.lmo`)) > 0; } catch (e) {} %} {% if (has_catalog): %} {% else %} {% endif %} {# the one asset stock LuCI leaves without a cache key. Must stay synchronous and un-deferred: luci.js captures `window.cbi_init` in its constructor, and `defer` is not an option for the catalogue above either — footer.ut runs `L.require('menu-footstrap')` inline while the parser is still running, so a module's `_()` would race a deferred window.TR. #}