🎄 Sync 2026-07-23 00:00:40

This commit is contained in:
github-actions[bot] 2026-07-23 00:00:40 +08:00
parent 9df7ccb682
commit 14f5c9168a
20 changed files with 582 additions and 143 deletions

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-cpu-status
PKG_VERSION:=0.6.3
PKG_RELEASE:=2
PKG_RELEASE:=3
LUCI_TITLE:=CPU utilization info for the LuCI status page
LUCI_PKGARCH:=all
PKG_LICENSE:=MIT

View File

@ -0,0 +1,58 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Project-Id-Version: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.3\n"
"Last-Translator: \n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
"Language: pl\n"
msgid "(%d minute window, %d second interval)"
msgstr "(%d minutowe okno, %d sekundowy interwał)"
msgid "All CPUs"
msgstr "Wszystkie procesory"
msgid "Average:"
msgstr "Średnia:"
msgid "Calculating"
msgstr "Obliczanie"
msgid "CPU"
msgstr "Procesor"
msgid "CPU Load"
msgstr "Obciążenie procesora"
msgid "CPU load"
msgstr "Obciążenie procesora"
msgid "Detailed load"
msgstr "Szczegółowe obciążenie"
msgid "Detailed load of each CPU"
msgstr "Szczegółowe obciążenie każdego procesora"
msgid "Load"
msgstr "Obciążenie"
msgid "Load of each CPU"
msgstr "Obciążenie każdego procesora"
msgid "Peak:"
msgstr "Szczytowe:"
msgid "This page displays the average CPU load at %d second interval."
msgstr "Ta strona wyświetla średnie obciążenie procesora w %d sekundowych odstępach."
msgid "Total"
msgstr "Łącznie"
msgid "Total load"
msgstr "Łączne obciążenie"

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.7.16
PKG_RELEASE:=190
PKG_RELEASE:=191
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \

View File

@ -590,6 +590,10 @@ if singbox_tags:find("with_utls") then
o:depends({ [_n("reality")] = true })
end
o = s:option(Flag, _n("anytls_disable_reuse"), translate("Disable TLS Reuse"))
o.default = 0
o:depends({ [_n("protocol")] = "anytls" })
o = s:option(ListValue, _n("transport"), translate("Transport"))
o:value("tcp", "TCP")
o:value("http", "HTTP")

View File

@ -613,6 +613,7 @@ function gen_outbound(flag, node, tag, proxy_table)
idle_session_check_interval = "30s",
idle_session_timeout = "30s",
min_idle_session = 5,
disable_reuse = (node.anytls_disable_reuse == "1") and true or nil,
tls = tls
}
end

View File

@ -1049,7 +1049,7 @@ function gen_config(var)
api.log(" - 加载 Xray 负载均衡 节点【" .. (_node.remarks or "") .. "】,子节点数量:" .. #(blc_nodes or {}))
local valid_nodes, valid_fallback = {}, true
local valid_nodes = {}
for i = 1, #(blc_nodes or {}) do
local blc_node_id = blc_nodes[i]
local blc_node_tag = "blc-" .. blc_node_id
@ -1069,14 +1069,14 @@ function gen_config(var)
end
-- Check if balancing node duplicates fallback node
if _node.fallback_node == blc_node_id then
valid_fallback = false
_node.fallback_node = nil
end
end
if #valid_nodes == 0 then return nil end
-- fallback node
local fallback_node_id = _node.fallback_node
fallback_node_id = (valid_fallback and fallback_node_id and fallback_node_id ~= "") and fallback_node_id or nil
fallback_node_id = (fallback_node_id and fallback_node_id ~= "") and fallback_node_id or nil
local fallback_node_tag = (fallback_node_id == "_direct") and "direct" or "blackhole"
if fallback_node_id and fallback_node_id ~= "_direct" then
local is_new_node = true

View File

@ -1749,7 +1749,7 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tls', true);
opt.set(dom_prefix + 'reality', false);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
@ -1760,7 +1760,7 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tls', true);
opt.set(dom_prefix + 'reality', true);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || '');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);

View File

@ -2195,3 +2195,6 @@ msgstr "Gecko 包大小(最大)"
msgid "valid time (hh:mm)"
msgstr "有效时间hh:mm"
msgid "Disable TLS Reuse"
msgstr "禁用 TLS 连接复用"

View File

@ -459,6 +459,8 @@ local function encode_anytls(node)
table.insert(p, "insecure=" .. (o.tls.insecure and "1" or "0"))
end
if node["disable-reuse"] then table.insert(p, "disable_reuse=1") end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end

View File

@ -1562,7 +1562,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
end
if params.security == "tls" or params.security == "reality" then
result.tls = "1"
result.tls_serverName = params.sni
result.tls_serverName = params.sni or params.peer
result.alpn = params.alpn
if params.fp and params.fp ~= "" then
result.utls = "1"
@ -1580,6 +1580,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
end
result.port = port
result.tls_allowInsecure = params.allowinsecure or params.insecure
result.anytls_disable_reuse = params.disable_reuse
end
elseif szType == 'naive+https' or szType == 'naive+quic' then
if has_singbox then

View File

@ -1,13 +1,18 @@
/**
* Header route search Spotlight-style palette, available in all three nav
* types (sidebar / mega-menu / dropdown). The toggle sits at the header's
* right edge next to #indicators; K / Ctrl+K toggles the panel globally.
* Command palette (K) Spotlight-style, available in all three nav types
* (sidebar / mega-menu / dropdown). The trigger sits at the header's right
* edge next to #indicators; K / Ctrl+K toggles the panel globally. Class
* names mirror luci-theme-shadcn's palette so the two themes stay legible
* side by side.
*
* The panel is built lazily by menu-aurora.js on first open; its index is
* the navigation model the menus already render from (no extra requests).
* the navigation model the menus already render from (no extra requests)
* plus three theme-mode command rows. An empty query browses the whole
* index; typed queries rank via a fuzzy subsequence scorer; a leading ">"
* (or full-width "") scopes the list to the command rows.
*/
.header-search-toggle {
.cmdk-trigger {
@apply text-text relative mr-4 grid size-8 shrink-0 cursor-pointer place-items-center rounded-lg border-0 bg-transparent p-0 shadow-none transition-colors duration-150;
/* Non-sidebar headers have no mr-auto crumb absorbing the slack pull the
@ -30,7 +35,7 @@
}
}
.header-search-panel {
.cmdk-panel {
@apply border-hairline bg-surface/85 fixed top-24 left-1/2 z-120 w-[min(36rem,calc(100vw-2.5rem))] -translate-x-1/2 overflow-hidden rounded-2xl border shadow-xl backdrop-blur-xl backdrop-saturate-150 transition-[opacity,translate] duration-150 ease-out starting:-translate-y-2 starting:opacity-0;
/* <md: the same panel becomes a full-screen takeover (Algolia DocSearch
@ -38,7 +43,7 @@
input bar pinned top with a Cancel exit, results fill the rest. */
@apply max-md:top-0 max-md:right-0 max-md:bottom-0 max-md:left-0 max-md:flex max-md:w-auto max-md:translate-none max-md:flex-col max-md:rounded-none max-md:border-0 max-md:bg-bg max-md:shadow-none max-md:backdrop-blur-none max-md:backdrop-saturate-100;
& .header-search-box {
& .cmdk-inputrow {
@apply flex items-center gap-3 px-5 py-3.5;
@apply max-md:border-hairline max-md:bg-surface max-md:border-b max-md:px-4 max-md:py-2.5;
@ -46,27 +51,27 @@
@apply text-text-subtle size-5 shrink-0 bg-current content-[''] [mask:url('@assets/icons/search.svg')_center/contain_no-repeat];
}
& .header-search-input {
& .cmdk-input {
@apply text-text placeholder-text-subtle w-full min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-base shadow-none focus:ring-0 focus:outline-none;
}
& .header-search-cancel {
& .cmdk-cancel {
@apply text-brand hidden shrink-0 cursor-pointer border-0 bg-transparent p-1 text-sm shadow-none max-md:block;
}
}
& .header-search-results {
& .cmdk-list {
@apply border-hairline max-h-80 overflow-y-auto border-t p-2 empty:hidden;
@apply max-md:max-h-none max-md:flex-1 max-md:overscroll-contain;
& .header-search-result {
& .cmdk-row {
@apply text-text flex items-center gap-3 rounded-lg px-3 py-2 text-sm no-underline;
& .result-title {
& .cmdk-title {
@apply min-w-0 truncate;
}
& .result-group {
& .cmdk-group-name {
@apply text-text-subtle ml-auto shrink-0 text-xs;
}
@ -77,21 +82,21 @@
&.is-selected {
@apply bg-brand-subtle;
& .result-title {
& .cmdk-title {
@apply text-brand font-medium;
}
}
}
& .header-search-empty {
& .cmdk-empty {
@apply text-text-subtle px-3 py-6 text-center text-sm;
}
}
& .header-search-hint {
& .cmdk-footer {
@apply border-hairline text-text-subtle flex items-center gap-4 border-t px-4 py-2 text-xs max-md:hidden;
& .header-search-hint-key {
& .cmdk-hint-close {
@apply ml-auto;
}
@ -103,13 +108,13 @@
/* The frost is decorative — both degradation paths go opaque. */
@supports not (backdrop-filter: blur(1px)) {
.header-search-panel {
.cmdk-panel {
@apply bg-surface;
}
}
@media (prefers-reduced-transparency: reduce) {
.header-search-panel {
.cmdk-panel {
@apply bg-surface;
}
}

View File

@ -38,7 +38,7 @@
@import "./components/_form.css";
@import "./components/_segmented.css";
@import "./components/_tab.css";
@import "./components/_search.css";
@import "./components/_palette.css";
@import "./_utilities.css";
@import "./_reduced-motion.css";

View File

@ -495,20 +495,20 @@ return baseclass.extend({
});
},
// Route search (all nav types): a Spotlight-style palette on ⌘K / Ctrl+K
// or the header toggle. The index is the navigation model the menus
// already render from — no extra requests, no DOM scraping — and the
// panel DOM is built lazily on first open, so pages where search is never
// used pay nothing beyond this flat array.
initSearch(items) {
const toggle = document.querySelector("#header-search-toggle");
if (!toggle || this.searchIndex) return;
// Command palette (all nav types): a Spotlight-style panel on ⌘K / Ctrl+K
// or the header trigger, holding routes and theme-mode commands. The index
// is the navigation model the menus already render from — no extra
// requests, no DOM scraping — and the panel DOM is built lazily on first
// open, so pages where it is never used pay nothing beyond this flat array.
initPalette(items) {
const toggle = document.querySelector("#cmdk-trigger");
if (!toggle || this.paletteIndex) return;
this.searchIndex = [];
this.paletteIndex = [];
items.forEach((item) => {
if (item.isLogout) return;
if (!item.hasChildren) {
this.searchIndex.push({
this.paletteIndex.push({
title: item.title,
name: item.name,
group: null,
@ -517,97 +517,137 @@ return baseclass.extend({
return;
}
item.pages.forEach((page) =>
this.searchIndex.push({
this.paletteIndex.push({
title: page.title,
name: page.name,
// Section-qualified: "status/overview" keeps English dispatch
// segments matchable under any UI language, and the "/" is what
// arms the scorer's segment-start bonus.
name: `${item.name}/${page.name}`,
group: item.title,
href: page.href,
}),
);
});
// The only non-navigation commands: theme modes. They ride the same
// index — matched and rendered like pages, grouped under _("Design")
// (the System → Language and Style label) — but execute header.ut's
// global setTheme() instead of navigating, so the panel stays open and
// previews the switch live. luci-base has no Light/Dark msgids — they
// stay English literals, wrapped in _() so a future catalog entry would
// take effect (the same trade the icon-only switcher makes).
[
["light", _("Light")],
["dark", _("Dark")],
["device", _("Automatic")],
].forEach(([mode, title]) =>
this.paletteIndex.push({
title,
name: `theme ${mode}`,
group: _("Design"),
mode,
}),
);
// Only msgids that already exist in the luci-base catalog are used —
// the theme intentionally ships no translations of its own.
const isMac = /Mac|iP(ad|hone|od)/.test(navigator.platform);
this.searchKey = isMac ? "⌘K" : "Ctrl+K";
this.paletteKey = isMac ? "⌘K" : "Ctrl+K";
toggle.setAttribute("aria-keyshortcuts", isMac ? "Meta+K" : "Control+K");
toggle.setAttribute("aria-expanded", "false");
toggle.addEventListener("click", () => this.toggleSearch());
this.searchToggle = toggle;
toggle.addEventListener("click", () => this.togglePalette());
this.paletteTrigger = toggle;
document.addEventListener("keydown", (e) => {
// An IME swallows these keys while composing (Esc cancels the
// composition, not the panel); keyCode 229 covers engines that
// don't set isComposing on the trailing keydown.
if (e.isComposing || e.keyCode === 229) return;
// Platform-exact modifier — only the advertised shortcut, with every
// other modifier rejected so combinations like Ctrl+Cmd+K fall through.
// On macOS, Ctrl+K is kill-to-end-of-line in text fields and must keep
// working.
if (
(e.metaKey || e.ctrlKey) &&
(isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey) &&
!e.altKey &&
!e.shiftKey &&
(e.key || "").toLowerCase() === "k"
) {
e.preventDefault();
this.toggleSearch();
this.togglePalette();
} else if (
e.key === "Escape" &&
this.searchPanel &&
!this.searchPanel.hidden
this.palettePanel &&
!this.palettePanel.hidden
) {
this.closeSearch();
this.closePalette();
}
});
// Registered only while the panel is open (see openSearch/closeSearch).
this.onSearchAway = (e) => {
if (!this.searchPanel.contains(e.target) && !toggle.contains(e.target))
this.closeSearch();
// Registered only while the panel is open (see openPalette/closePalette).
this.onPaletteAway = (e) => {
if (!this.palettePanel.contains(e.target) && !toggle.contains(e.target))
this.closePalette();
};
},
buildSearchPanel() {
buildPalette() {
// type=search gets WebKit's native clear button for free; the combobox
// wiring (with role=option rows) is what makes arrow-key selection
// audible to screen readers — visually it's CSS-only .is-selected.
const input = E("input", {
class: "header-search-input",
type: "text",
class: "cmdk-input",
type: "search",
enterkeyhint: "go",
placeholder: _("Type to filter…"),
"aria-label": _("Type to filter…"),
autocomplete: "off",
spellcheck: "false",
role: "combobox",
"aria-expanded": "true",
"aria-autocomplete": "list",
"aria-controls": "cmdk-list",
});
const results = E("div", {
class: "cmdk-list",
id: "cmdk-list",
role: "listbox",
});
const results = E("div", { class: "header-search-results" });
// Mobile-only exit (the full-screen takeover leaves no outside to tap
// and touch devices have no Escape) — hidden on md+ via CSS.
const cancel = E(
"button",
{ class: "header-search-cancel", type: "button" },
{ class: "cmdk-cancel", type: "button" },
[_("Cancel")],
);
cancel.addEventListener("click", () => this.closeSearch());
cancel.addEventListener("click", () => this.closePalette());
const panel = E(
"div",
{
id: "header-search-panel",
class: "header-search-panel",
id: "cmdk-panel",
class: "cmdk-panel",
role: "dialog",
"aria-modal": "true",
"aria-label": _("Navigation"),
hidden: "",
},
[
E("div", { class: "header-search-box" }, [input, cancel]),
E("div", { class: "cmdk-inputrow" }, [input, cancel]),
results,
E("div", { class: "header-search-hint" }, [
E("div", { class: "cmdk-footer" }, [
E("kbd", {}, ["↑↓"]),
E("kbd", {}, ["↵"]),
E("kbd", {}, [">"]),
E("kbd", {}, ["esc"]),
E("span", { class: "header-search-hint-key" }, [
E("kbd", {}, [this.searchKey]),
E("span", { class: "cmdk-hint-close" }, [
E("kbd", {}, [this.paletteKey]),
]),
]),
],
);
input.addEventListener("input", () =>
this.renderSearchResults(input.value),
this.renderPaletteResults(input.value),
);
input.addEventListener("keydown", (e) => {
// Mid-composition these keys belong to the IME: Enter commits the
@ -616,7 +656,7 @@ return baseclass.extend({
if (e.isComposing || e.keyCode === 229) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
this.moveSearchSelection(e.key === "ArrowDown" ? 1 : -1);
this.movePaletteSelection(e.key === "ArrowDown" ? 1 : -1);
} else if (e.key === "Enter") {
results.querySelector(".is-selected")?.click();
}
@ -625,9 +665,24 @@ return baseclass.extend({
// stationary pointer, which fires mouseover and would snap the
// selection back to whatever the mouse happens to rest on.
results.addEventListener("mousemove", (e) => {
const row = e.target?.closest?.(".header-search-result");
const row = e.target?.closest?.(".cmdk-row");
if (row && !row.classList.contains("is-selected"))
this.setSearchSelection(row);
this.setPaletteSelection(row);
});
// Theme rows execute instead of navigate: hand the mode to header.ut's
// global setTheme/syncSwitchers, keep the panel open so the switch
// previews live, and re-render so the ✓ follows. Refocus the input —
// the clicked anchor is replaced by the re-render, which would strand
// focus on <body> with the dialog still open.
results.addEventListener("click", (e) => {
const mode = e.target?.closest?.(".cmdk-row")?.dataset.mode;
if (!mode) return;
e.preventDefault();
setTheme(mode);
syncSwitchers();
this.renderPaletteResults(input.value);
this.setPaletteSelection(results.querySelector(`[data-mode="${mode}"]`));
input.focus();
});
// The dialog is modal (full-screen takeover on mobile): keep Tab
// cycling within it instead of escaping onto the page beneath.
@ -646,73 +701,176 @@ return baseclass.extend({
});
document.body.appendChild(panel);
this.searchToggle.setAttribute("aria-controls", panel.id);
this.searchPanel = panel;
this.searchInput = input;
this.searchResults = results;
this.paletteTrigger.setAttribute("aria-controls", panel.id);
this.palettePanel = panel;
this.paletteInput = input;
this.paletteList = results;
},
renderSearchResults(value) {
const q = value.trim().toLowerCase();
this.searchResults.replaceChildren();
if (!q) return; // Spotlight manner: quiet until typed.
// Greedy left-to-right subsequence scorer: adjacency runs and word starts
// score up, longer targets score down. Retried from every occurrence of
// the query's first character — a single greedy pass would anchor "dns"
// to the first d of "Dhcp and dNS" and scatter, scoring it below shorter
// near-misses like "Diagnostics". Query arrives lowercased; spaces only
// reset the adjacency run.
fuzzyMatch(q, text) {
const low = String(text).toLowerCase();
let best = null;
const matches = this.searchIndex.filter(
(page) =>
page.title.toLowerCase().includes(q) ||
page.name.toLowerCase().includes(q) ||
page.group?.toLowerCase().includes(q),
);
for (
let from = low.indexOf(q[0]);
from >= 0;
from = low.indexOf(q[0], from + 1)
) {
let ti = from;
let score = 0;
let run = 0;
const ranges = [];
for (const c of q) {
if (c === " ") {
run = 0;
continue;
}
const at = low.indexOf(c, ti);
if (at < 0) {
ranges.length = 0;
break;
}
run = at === ti && ranges.length ? run + 1 : 1;
score +=
1 +
run +
(at === ti && ranges.length ? 4 : 0) +
(at === 0 || low[at - 1] === " " || low[at - 1] === "/" ? 3 : 0);
// c is a code point, so it can be two UTF-16 units wide (emoji, CJK
// Ext-B); advancing by c.length keeps the ranges — which index the
// original string — from slicing a surrogate pair in half.
const end = at + c.length;
const last = ranges[ranges.length - 1];
if (last && last[1] === at) last[1] = end;
else ranges.push([at, end]);
ti = end;
}
score -= low.length * 0.02;
if (ranges.length && (!best || score > best.score))
best = { score, ranges };
}
return best;
},
// Empty query matches everything at score 0 (the browse list); title hits
// outrank name/path and group hits and are the only ones highlighted.
matchPaletteEntry(q, page) {
if (!q) return { score: 0, ranges: null };
const title = this.fuzzyMatch(q, page.title);
if (title) return { score: title.score + 12, ranges: title.ranges };
const rest =
this.fuzzyMatch(q, page.name) ||
(page.group ? this.fuzzyMatch(q, page.group) : null);
return rest && { score: rest.score, ranges: null };
},
renderPaletteResults(value) {
let q = value.trim().toLowerCase();
// A leading ">" scopes the list to the command rows (the prototype's
// command-only mode); "" covers CJK IMEs emitting the full-width form.
const cmdOnly = q[0] === ">" || q[0] === "";
if (cmdOnly) q = q.slice(1).trim();
const theme = localStorage.getItem("aurora.theme") || "device";
const matches = [];
for (const page of this.paletteIndex) {
if (cmdOnly && !page.mode) continue;
const m = this.matchPaletteEntry(q, page);
if (m) matches.push({ page, score: m.score, ranges: m.ranges });
}
// sort() is spec-stable: equal scores keep menu order (and the browse
// list stays in menu order by skipping the sort entirely).
if (q) matches.sort((a, b) => b.score - a.score);
this.paletteInput.removeAttribute("aria-activedescendant");
this.paletteList.replaceChildren();
if (!matches.length) {
this.searchResults.appendChild(
E("div", { class: "header-search-empty" }, [_("No entries available")]),
this.paletteList.appendChild(
E("div", { class: "cmdk-empty" }, [_("No entries available")]),
);
return;
}
matches.forEach((page, i) => {
this.searchResults.appendChild(
E(
"a",
{
class: `header-search-result${i ? "" : " is-selected"}`,
href: page.href,
},
[
E(
"span",
{ class: "result-title" },
this.highlightSearchMatch(page.title, q),
),
page.group ? E("span", { class: "result-group" }, [page.group]) : "",
],
),
matches.forEach(({ page, ranges }, i) => {
const current = page.mode && page.mode === theme;
const attributes = {
class: "cmdk-row",
id: `cmdk-option-${i}`,
role: "option",
"aria-selected": "false",
href: page.href || "#",
};
if (page.mode) attributes["data-mode"] = page.mode;
if (current) attributes["aria-current"] = "true";
this.paletteList.appendChild(
E("a", attributes, [
E(
"span",
{ class: "cmdk-title" },
this.highlightPaletteMatch(page.title, ranges),
),
current
? // The ✓ is decorative (aria-current carries the state); mark
// reuses the highlight colour without any new CSS.
E("span", { class: "cmdk-group-name", "aria-hidden": "true" }, [
E("mark", {}, ["✓"]),
])
: page.group
? E("span", { class: "cmdk-group-name" }, [page.group])
: "",
]),
);
});
this.setPaletteSelection(this.paletteList.firstChild);
},
highlightSearchMatch(title, q) {
const at = title.toLowerCase().indexOf(q);
if (at < 0) return [title];
highlightPaletteMatch(title, ranges) {
// Ranges were measured on the lowercased copy; case folding can change
// string length ("İ" → "i̇"), skewing offsets into the original — skip
// highlighting rather than mis-slice.
if (!ranges || title.toLowerCase().length !== title.length) return [title];
return [
title.slice(0, at),
E("mark", {}, [title.slice(at, at + q.length)]),
title.slice(at + q.length),
];
const parts = [];
let last = 0;
ranges.forEach(([from, to]) => {
parts.push(
title.slice(last, from),
E("mark", {}, [title.slice(from, to)]),
);
last = to;
});
parts.push(title.slice(last));
return parts;
},
setSearchSelection(row) {
this.searchResults
.querySelector(".is-selected")
?.classList.remove("is-selected");
setPaletteSelection(row) {
const prev = this.paletteList.querySelector(".is-selected");
prev?.classList.remove("is-selected");
prev?.setAttribute("aria-selected", "false");
row.classList.add("is-selected");
row.setAttribute("aria-selected", "true");
this.paletteInput.setAttribute("aria-activedescendant", row.id);
},
moveSearchSelection(delta) {
movePaletteSelection(delta) {
const rows = [
...this.searchResults.querySelectorAll(".header-search-result"),
...this.paletteList.querySelectorAll(".cmdk-row"),
];
if (!rows.length) return;
@ -721,33 +879,33 @@ return baseclass.extend({
);
const next = rows[(current + delta + rows.length) % rows.length];
this.setSearchSelection(next);
this.setPaletteSelection(next);
next.scrollIntoView({ block: "nearest" });
},
toggleSearch() {
if (!this.searchPanel) this.buildSearchPanel();
togglePalette() {
if (!this.palettePanel) this.buildPalette();
if (this.searchPanel.hidden) this.openSearch();
else this.closeSearch();
if (this.palettePanel.hidden) this.openPalette();
else this.closePalette();
},
openSearch() {
this.searchReturnFocus = document.activeElement;
this.searchToggle.setAttribute("aria-expanded", "true");
this.searchPanel.hidden = false;
this.searchInput.value = "";
this.searchResults.replaceChildren();
this.searchInput.focus();
document.addEventListener("pointerdown", this.onSearchAway);
openPalette() {
this.paletteReturnFocus = document.activeElement;
this.paletteTrigger.setAttribute("aria-expanded", "true");
this.palettePanel.hidden = false;
this.paletteInput.value = "";
this.renderPaletteResults("");
this.paletteInput.focus();
document.addEventListener("pointerdown", this.onPaletteAway);
},
closeSearch() {
this.searchToggle.setAttribute("aria-expanded", "false");
this.searchPanel.hidden = true;
document.removeEventListener("pointerdown", this.onSearchAway);
if (this.searchReturnFocus?.isConnected) this.searchReturnFocus.focus();
this.searchReturnFocus = null;
closePalette() {
this.paletteTrigger.setAttribute("aria-expanded", "false");
this.palettePanel.hidden = true;
document.removeEventListener("pointerdown", this.onPaletteAway);
if (this.paletteReturnFocus?.isConnected) this.paletteReturnFocus.focus();
this.paletteReturnFocus = null;
},
// Shared scaffolding for the two desktop dropdown modes (mega-menu and
@ -1152,7 +1310,7 @@ return baseclass.extend({
);
this.renderMainMenu(activeChild, activeChild.name, 0, navigationItems);
this.renderMobileMenu(navigationItems);
this.initSearch(navigationItems);
this.initPalette(navigationItems);
}
if (ul?.children.length > 1) {

View File

@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";
const source = await readFile(
new URL("../src/resource/menu-aurora.js", import.meta.url),
"utf8",
);
const E = (tagName, attributes, children) => ({
tagName,
attributes,
children,
});
const loadMenuModule = () => {
const baseclass = {
extend(module) {
return module;
},
};
const ui = { menu: { getChildren: () => [] } };
const L = {
env: { dispatchpath: [], requestpath: [] },
url: (...segments) => `/${segments.join("/")}`,
};
return new Function(
"baseclass",
"ui",
"E",
"L",
"_",
"document",
"window",
"localStorage",
source,
)(baseclass, ui, E, L, (value) => value, {}, {}, {});
};
const menu = loadMenuModule();
const score = (query, text) => menu.fuzzyMatch(query, text)?.score ?? null;
const sliced = (text, ranges) =>
ranges.map(([from, to]) => text.slice(from, to));
test("matches a scattered subsequence and reports its ranges", () => {
const match = menu.fuzzyMatch("dns", "DHCP and DNS");
assert.ok(match);
assert.deepEqual(sliced("DHCP and DNS", match.ranges), ["DNS"]);
});
test("returns null when the query is not a subsequence", () => {
assert.equal(menu.fuzzyMatch("zzz", "Firewall"), null);
});
test("retries from every first-character occurrence instead of staying greedy", () => {
// A single greedy pass anchors "dns" to the leading D of "DHCP and DNS" and
// scatters, scoring the real hit below shorter near-misses.
assert.ok(score("dns", "DHCP and DNS") > score("dns", "Diagnostics"));
});
test("scores an adjacency run above the same characters scattered", () => {
assert.ok(score("fir", "Firewall") > score("fir", "Failover in Routing"));
});
test("rewards word starts", () => {
assert.ok(score("s", "Status") > score("s", "Wireless"));
});
test("penalises longer targets so the tighter title wins", () => {
assert.ok(score("log", "Logout") > score("log", "Logging and reporting"));
});
test("matches CJK titles", () => {
const match = menu.fuzzyMatch("防火墙", "防火墙设置");
assert.ok(match);
assert.deepEqual(sliced("防火墙设置", match.ranges), ["防火墙"]);
});
test("keeps astral characters whole in the reported ranges", () => {
// `at` is a UTF-16 offset while for...of yields code points: advancing by 1
// would end the range inside the surrogate pair and slice it into mojibake.
const title = "🚀 Passwall";
const match = menu.fuzzyMatch("🚀", title);
assert.ok(match);
assert.deepEqual(match.ranges, [[0, 2]]);
assert.deepEqual(sliced(title, match.ranges), ["🚀"]);
});
test("merges adjacent astral matches into one range", () => {
const title = "𠀀𠀁 Extension";
const match = menu.fuzzyMatch("𠀀𠀁", title);
assert.ok(match);
assert.deepEqual(match.ranges, [[0, 4]]);
assert.deepEqual(sliced(title, match.ranges), ["𠀀𠀁"]);
});
test("an empty query matches every entry unranked and unhighlighted", () => {
assert.deepEqual(menu.matchPaletteEntry("", { title: "Status" }), {
score: 0,
ranges: null,
});
});
test("title hits outrank name hits and are the only highlighted ones", () => {
const title = menu.matchPaletteEntry("dns", {
title: "DNS",
name: "dns",
group: "Network",
});
const viaName = menu.matchPaletteEntry("dhcp", {
title: "DNS",
name: "dhcp",
group: "Network",
});
assert.ok(title.ranges);
assert.equal(viaName.ranges, null);
assert.ok(title.score > viaName.score);
});
test("falls back to the group when neither title nor name matches", () => {
const match = menu.matchPaletteEntry("netw", {
title: "DNS",
name: "dns",
group: "Network",
});
assert.ok(match);
assert.equal(match.ranges, null);
});
test("returns falsy when nothing on the entry matches", () => {
assert.ok(
!menu.matchPaletteEntry("zzz", {
title: "DNS",
name: "dns",
group: "Network",
}),
);
});
test("wraps matched ranges in <mark> and keeps the surrounding text", () => {
const parts = menu.highlightPaletteMatch("DHCP and DNS", [[9, 12]]);
assert.deepEqual(
parts.map((part) => (typeof part === "string" ? part : part.children[0])),
["DHCP and ", "DNS", ""],
);
assert.equal(parts[1].tagName, "mark");
});
test("highlights astral characters without splitting the surrogate pair", () => {
const parts = menu.highlightPaletteMatch("🚀 Passwall", [[0, 2]]);
assert.equal(parts[1].children[0], "🚀");
assert.ok(!parts.some((part) => String(part).includes("<22>")));
});
test("skips highlighting when case folding would skew the offsets", () => {
// "İ".toLowerCase() is two code units, so ranges measured on the lowercased
// copy no longer index the original.
assert.deepEqual(menu.highlightPaletteMatch("İnterface", [[0, 1]]), [
"İnterface",
]);
});
test("renders the plain title when there are no ranges", () => {
assert.deepEqual(menu.highlightPaletteMatch("Status", null), ["Status"]);
});

View File

@ -6,10 +6,10 @@
import tailwindcss from "@tailwindcss/vite";
import browserslist from "browserslist";
import { exec } from "child_process";
import { existsSync, readdirSync } from "fs";
import { existsSync, readdirSync, statSync } from "fs";
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
import { browserslistToTargets } from "lightningcss";
import { basename, dirname, join, relative, resolve } from "path";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { minify as terserMinify } from "terser";
import { promisify } from "util";
import {
@ -73,6 +73,30 @@ function createLuciJsCompressPlugin(): Plugin {
const PATCH_PUBLIC_PREFIX = "/luci-static/aurora/patches/";
const PATCH_SRC_DIR = resolve(CURRENT_DIR, "src/media/patches");
// Theme assets (public/aurora/**: images, fonts): serve the copy in this
// checkout at /luci-static/aurora/<path>. Without this, header.ut's logo,
// favicon, webmanifest and font references fall through to the OpenWrt proxy
// and resolve against the *installed* package, so an icon or font added here
// 404s until the package is rebuilt and reinstalled. Only files that exist
// locally are rewritten — everything else still proxies through.
const ASSET_PUBLIC_PREFIX = "/luci-static/aurora/";
const ASSET_SRC_DIR = resolve(CURRENT_DIR, "public/aurora");
// Resolves a request path under public/aurora/, or null when it escapes the
// directory (../) or names something that is not a file there.
function localAsset(relPath: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(relPath);
} catch {
return null;
}
const file = resolve(ASSET_SRC_DIR, decoded);
if (!file.startsWith(ASSET_SRC_DIR + sep)) return null;
return existsSync(file) && statSync(file).isFile() ? decoded : null;
}
function createLocalServePlugin(): Plugin {
const cssRoutes: Record<string, string> = {
"/luci-static/aurora/main.css": "/src/media/main.css",
@ -118,6 +142,13 @@ function createLocalServePlugin(): Plugin {
return next();
}
}
if (pathname.startsWith(ASSET_PUBLIC_PREFIX)) {
const asset = localAsset(pathname.slice(ASSET_PUBLIC_PREFIX.length));
if (asset) {
req.url = `/aurora/${asset}` + (search ? `?${search}` : "");
return next();
}
}
const jsPath = jsRoutes[pathname];
if (jsPath) {
try {

View File

@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.1.0
PKG_RELEASE:=50
PKG_VERSION:=1.1.1
PKG_RELEASE:=51
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -234,7 +234,7 @@
{% if (nav_type == 'sidebar'): %}
<ol class="header-crumb" id="header-crumb"></ol>
{% endif %}
<button class="header-search-toggle" id="header-search-toggle" type="button" aria-label="{{ _('Navigation') }}"></button>
<button class="cmdk-trigger" id="cmdk-trigger" type="button" aria-label="{{ _('Navigation') }}"></button>
<div id="indicators" class="pull-right"></div>
</div>