From ea54530de10129b982970021ad577c07b7e402de Mon Sep 17 00:00:00 2001 From: wzd8888 <1255891717@qq.com> Date: Mon, 19 Oct 2026 18:49:25 +0800 Subject: [PATCH] fix(themehost): keep the active theme first and sort the rest naturally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress lists the active theme first and sorts the other names with case ignored and numbers in natural order (wp_prepare_themes_for_js / WP_Theme::sort_by_name). Manager.List returned the library byte order, so Zoe came before alba and theme10 before theme2, and the active theme kept its place. Manager.List now puts the active theme first and sorts the rest with a strnatcasecmp-style comparison. Regression coverage uses Zoe, alba, theme10, theme2 and Álvaro with theme2 active. Refs #509 Signed-off-by: wzd8888 <1255891717@qq.com> --- internal/themehost/manager.go | 2 +- internal/themehost/manager_list_order_test.go | 42 ++++++++ internal/themehost/order.go | 97 +++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 internal/themehost/manager_list_order_test.go create mode 100644 internal/themehost/order.go diff --git a/internal/themehost/manager.go b/internal/themehost/manager.go index 7865698c..995c8223 100644 --- a/internal/themehost/manager.go +++ b/internal/themehost/manager.go @@ -86,7 +86,7 @@ func (m *Manager) List(ctx context.Context) ([]Installed, error) { Active: true, }) } - return installed, nil + return ordered(installed), nil } // mark labels each theme with the operator's choice and the one answering, reporting the choice found. diff --git a/internal/themehost/manager_list_order_test.go b/internal/themehost/manager_list_order_test.go new file mode 100644 index 00000000..87e28c53 --- /dev/null +++ b/internal/themehost/manager_list_order_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +package themehost_test + +import ( + "context" + "testing" + + "github.com/gopherium/gophenberg/internal/themehost" +) + +// WordPress keeps the active theme on top and sorts the rest with case ignored +// and numbers in natural order. Sorting by raw bytes put a capitalised or +// accented name out of place and read theme10 before theme2 (#509). +func TestManagerListPutsTheActiveThemeFirstAndSortsTheRestNaturally(t *testing.T) { + t.Parallel() + + settings := newSettings() + settings.values[themehost.ActiveKey] = "theme2" + manager := managerOver(t, settings, "", "Zoe", "theme10", "alba", "Álvaro", "theme2") + + installed, err := manager.List(context.Background()) + if err != nil { + t.Fatal(err) + } + got := make([]string, len(installed)) + for i, theme := range installed { + got[i] = theme.Name + } + want := []string{"theme2", "alba", "theme10", "Zoe", "Álvaro"} + if len(got) != len(want) { + t.Fatalf("listed %d themes, want %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("theme order = %v, want %v", got, want) + } + } + if !installed[0].Active { + t.Fatalf("the first theme is %q, not the active one", installed[0].Name) + } +} diff --git a/internal/themehost/order.go b/internal/themehost/order.go new file mode 100644 index 00000000..c992602f --- /dev/null +++ b/internal/themehost/order.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package themehost + +import ( + "sort" + "unicode" +) + +// ordered puts the active theme first and sorts the rest by name with case +// ignored and digit runs compared by value. WordPress builds its themes screen +// with wp_prepare_themes_for_js, which keeps the active theme on top and sorts +// the rest with WP_Theme::sort_by_name; raw byte order moved a capitalised or +// accented name out of place and read theme10 before theme2 (#509). +func ordered(themes []Installed) []Installed { + sort.SliceStable(themes, func(i, j int) bool { + if themes[i].Active != themes[j].Active { + return themes[i].Active + } + return naturalLess(themes[i].Name, themes[j].Name) + }) + return themes +} + +// naturalLess compares two names the way PHP's strnatcasecmp does: letters +// case-insensitively, and each run of digits by its numeric value so theme2 +// comes before theme10. +func naturalLess(a, b string) bool { return naturalCompare(a, b) < 0 } + +func naturalCompare(a, b string) int { + ar, br := []rune(a), []rune(b) + i, j := 0, 0 + for i < len(ar) && j < len(br) { + if isDigit(ar[i]) && isDigit(br[j]) { + ai, bj := i, j + for ai < len(ar) && isDigit(ar[ai]) { + ai++ + } + for bj < len(br) && isDigit(br[bj]) { + bj++ + } + if c := compareDigits(string(ar[i:ai]), string(br[j:bj])); c != 0 { + return c + } + i, j = ai, bj + continue + } + al, bl := unicode.ToLower(ar[i]), unicode.ToLower(br[j]) + if al != bl { + if al < bl { + return -1 + } + return 1 + } + i++ + j++ + } + switch { + case i < len(ar): + return 1 + case j < len(br): + return -1 + } + return 0 +} + +func compareDigits(a, b string) int { + at, bt := trimLeadingZeros(a), trimLeadingZeros(b) + if len(at) != len(bt) { + if len(at) < len(bt) { + return -1 + } + return 1 + } + if at != bt { + if at < bt { + return -1 + } + return 1 + } + if len(a) != len(b) { + if len(a) < len(b) { + return -1 + } + return 1 + } + return 0 +} + +func trimLeadingZeros(s string) string { + for len(s) > 1 && s[0] == '0' { + s = s[1:] + } + return s +} + +func isDigit(r rune) bool { return r >= '0' && r <= '9' }