{#
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 #}