From a0d55c58f6a00c20dbf5deba56585838b9481157 Mon Sep 17 00:00:00 2001
From: guqing <38999863+guqing@users.noreply.github.com>
Date: Thu, 15 Sep 2022 14:52:13 +0800
Subject: [PATCH] feat: theme side provides variables for theme and some system
 settings (#2406)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

#### What type of PR is this?
/kind feature
/milestone 2.0
/area core

#### What this PR does / why we need it:
提供当前使用主题(预览或激活)的 configMap 变量和部分系统设置等变量。

提供了以下变量:
- `${theme}` 当前主题的信息,theme.yaml
- `${theme.config}` 获取当前主题的设置项
- ~`${siteSetting}`~ `${site}` 提供必要系统变量

#### Which issue(s) this PR fixes:
Fixes #2389

#### Special notes for your reviewer:
how to test it?
再任意主题模板上使用表达式获取例如:`${theme}`,`${theme.config.sns?.email}`

/cc @halo-dev/sig-halo
#### Does this PR introduce a user-facing change?

```release-note
None
```
---
 build.gradle                                  |   1 +
 src/main/java/run/halo/app/Application.java   |   4 +-
 .../reconciler/SystemSettingReconciler.java   | 261 ++++++++++++------
 .../SystemConfigurableEnvironmentFetcher.java |   6 +-
 .../run/halo/app/infra/SystemSetting.java     |  40 +++
 .../run/halo/app/theme/HaloViewResolver.java  |  51 +++-
 .../theme/SiteSettingVariablesAcquirer.java   |  34 +++
 .../ThemeContextBasedVariablesAcquirer.java   |  41 +++
 .../ViewContextBasedVariablesAcquirer.java    |  11 +
 .../theme/dialect/HaloProcessorDialect.java   |   3 +-
 ...dePropertyAccessorBoundariesProcessor.java |  45 +++
 .../halo/app/theme/finders/ThemeFinder.java   |  16 ++
 .../theme/finders/impl/ThemeFinderImpl.java   |  68 +++++
 .../app/theme/finders/vo/SiteSettingVo.java   | 121 ++++++++
 .../halo/app/theme/finders/vo/ThemeVo.java    |  42 +++
 .../resources/extensions/system-setting.yaml  |   2 +-
 .../SystemSettingReconcilerTest.java          | 144 +++++++++-
 .../ThemeJava8TimeDialectIntegrationTest.java |   7 +-
 18 files changed, 796 insertions(+), 101 deletions(-)
 create mode 100644 src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java
 create mode 100644 src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java
 create mode 100644 src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java
 create mode 100644 src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java
 create mode 100644 src/main/java/run/halo/app/theme/finders/ThemeFinder.java
 create mode 100644 src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java
 create mode 100644 src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java
 create mode 100644 src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java

diff --git a/build.gradle b/build.gradle
index d2b802949..4d9c1d9be 100644
--- a/build.gradle
+++ b/build.gradle
@@ -74,6 +74,7 @@ dependencies {
     implementation "com.google.guava:guava:$guava"
     implementation "org.jsoup:jsoup:$jsoup"
     implementation "io.github.java-diff-utils:java-diff-utils:$javaDiffUtils"
+    implementation "org.springframework.integration:spring-integration-core"
 
     compileOnly 'org.projectlombok:lombok'
     testCompileOnly 'org.projectlombok:lombok'
diff --git a/src/main/java/run/halo/app/Application.java b/src/main/java/run/halo/app/Application.java
index a7f8a8e5a..b3c1c8b66 100644
--- a/src/main/java/run/halo/app/Application.java
+++ b/src/main/java/run/halo/app/Application.java
@@ -2,6 +2,7 @@ package run.halo.app;
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import run.halo.app.infra.properties.HaloProperties;
 import run.halo.app.infra.properties.JwtProperties;
@@ -14,7 +15,8 @@ import run.halo.app.infra.properties.JwtProperties;
  * @author guqing
  * @date 2017-11-14
  */
-@SpringBootApplication
+@SpringBootApplication(scanBasePackages = "run.halo.app", exclude =
+    IntegrationAutoConfiguration.class)
 @EnableConfigurationProperties({HaloProperties.class, JwtProperties.class})
 public class Application {
 
diff --git a/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java
index fafda15f4..82731ccc0 100644
--- a/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java
+++ b/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java
@@ -1,7 +1,10 @@
 package run.halo.app.core.extension.reconciler;
 
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.context.ApplicationContext;
 import run.halo.app.extension.ConfigMap;
@@ -19,13 +22,16 @@ import run.halo.app.theme.router.PermalinkRuleChangedEvent;
  * @author guqing
  * @since 2.0.0
  */
+@Slf4j
 public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
-
-    private static final String OLD_THEME_ROUTE_RULES = "halo.run/old-theme-route-rules";
+    public static final String OLD_THEME_ROUTE_RULES = "halo.run/old-theme-route-rules";
+    public static final String FINALIZER_NAME = "system-setting-protection";
 
     private final ExtensionClient client;
     private final ApplicationContext applicationContext;
 
+    private final RouteRuleReconciler routeRuleReconciler = new RouteRuleReconciler();
+
     public SystemSettingReconciler(ExtensionClient client, ApplicationContext applicationContext) {
         this.client = client;
         this.applicationContext = applicationContext;
@@ -39,110 +45,197 @@ public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
         }
         client.fetch(ConfigMap.class, name)
             .ifPresent(configMap -> {
-                ConfigMap oldConfigMap = JsonUtils.deepCopy(configMap);
-
-                ruleChangedDispatcher(configMap);
-
-                if (!configMap.equals(oldConfigMap)) {
-                    client.update(configMap);
-                }
+                addFinalizerIfNecessary(configMap);
+                routeRuleReconciler.reconcile(name);
             });
         return new Result(false, null);
     }
 
-    private void ruleChangedDispatcher(ConfigMap configMap) {
-        Map<String, String> data = configMap.getData();
+    private void addFinalizerIfNecessary(ConfigMap oldConfigMap) {
+        Set<String> finalizers = oldConfigMap.getMetadata().getFinalizers();
+        if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
+            return;
+        }
+        client.fetch(ConfigMap.class, oldConfigMap.getMetadata().getName())
+            .ifPresent(configMap -> {
+                Set<String> newFinalizers = configMap.getMetadata().getFinalizers();
+                if (newFinalizers == null) {
+                    newFinalizers = new HashSet<>();
+                    configMap.getMetadata().setFinalizers(newFinalizers);
+                }
+                newFinalizers.add(FINALIZER_NAME);
+                client.update(configMap);
+            });
+    }
 
-        Map<String, String> annotations = getAnnotationsSafe(configMap);
-        String oldRulesJson = annotations.get(OLD_THEME_ROUTE_RULES);
+    class RouteRuleReconciler {
 
-        String routeRulesJson = data.get(SystemSetting.ThemeRouteRules.GROUP);
-        // get new rules and replace old rules to new rules
-        SystemSetting.ThemeRouteRules newRouteRules =
-            JsonUtils.jsonToObject(routeRulesJson, SystemSetting.ThemeRouteRules.class);
-
-        // old rules is empty, means this is the first time to update theme route rules
-        if (oldRulesJson == null) {
-            oldRulesJson = "{}";
+        public  void reconcile(String name) {
+            reconcileArchivesRule(name);
+            reconcileTagsRule(name);
+            reconcileCategoriesRule(name);
+            reconcilePostRule(name);
         }
 
-        // diff old rules and new rules
-        SystemSetting.ThemeRouteRules oldRules =
-            JsonUtils.jsonToObject(oldRulesJson, SystemSetting.ThemeRouteRules.class);
+        private void reconcileArchivesRule(String name) {
+            client.fetch(ConfigMap.class, name).ifPresent(configMap -> {
+                SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap);
+                SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap);
 
-        // dispatch event
-        if (!StringUtils.equals(oldRules.getArchives(), newRouteRules.getArchives())) {
-            // archives rule changed
-            applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
-                DefaultTemplateEnum.ARCHIVES,
-                newRouteRules.getArchives()));
+                final String oldArchivesPrefix = oldRules.getArchives();
+                final String oldPostPattern = oldRules.getPost();
+
+                // dispatch event
+                final boolean archivesPrefixChanged =
+                    !StringUtils.equals(oldRules.getArchives(), newRules.getArchives());
+
+                final boolean postPatternChanged =
+                    changePostPatternPrefixIfNecessary(oldArchivesPrefix, newRules);
+
+                if (archivesPrefixChanged || postPatternChanged) {
+                    oldRules.setPost(newRules.getPost());
+                    oldRules.setArchives(newRules.getArchives());
+                    updateNewRuleToConfigMap(configMap, oldRules, newRules);
+                }
+
+                // archives rule changed
+                if (archivesPrefixChanged) {
+                    log.debug("Archives prefix changed from [{}] to [{}].", oldArchivesPrefix,
+                        newRules.getArchives());
+                    applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
+                        DefaultTemplateEnum.ARCHIVES,
+                        newRules.getArchives()));
+                }
+
+                if (postPatternChanged) {
+                    log.debug("Post pattern changed from [{}] to [{}].", oldPostPattern,
+                        newRules.getPost());
+                    // post rule changed
+                    applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
+                        DefaultTemplateEnum.POST, newRules.getPost()));
+                }
+            });
         }
 
-        if (!StringUtils.equals(oldRules.getTags(), newRouteRules.getTags())) {
-            // tags rule changed
-            applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
-                DefaultTemplateEnum.TAGS,
-                newRouteRules.getTags()));
-        }
-
-        if (!StringUtils.equals(oldRules.getCategories(), newRouteRules.getCategories())) {
-            // categories rule changed
-            applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
-                DefaultTemplateEnum.CATEGORIES,
-                newRouteRules.getCategories()));
-        }
-
-        if (!StringUtils.equals(oldRules.getPost(), newRouteRules.getPost())) {
-            // post rule changed
-            applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
-                DefaultTemplateEnum.POST,
-                newRouteRules.getPost()));
-        }
-
-        // TODO 此处立即同步 post 的新 pattern 到数据库,才能更新到文章页面的 permalink 地址
-        //   但会导致乐观锁失效会失败一次 reconcile
-        if (changePostPatternPrefixIfNecessary(oldRules, newRouteRules)) {
-            data.put(SystemSetting.ThemeRouteRules.GROUP, JsonUtils.objectToJson(newRouteRules));
-            annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(newRouteRules));
-            // update config map immediately
+        private void updateNewRuleToConfigMap(ConfigMap configMap,
+            SystemSetting.ThemeRouteRules oldRules,
+            SystemSetting.ThemeRouteRules newRules) {
+            Map<String, String> annotations = getAnnotationsSafe(configMap);
+            annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(oldRules));
+            configMap.getData().put(SystemSetting.ThemeRouteRules.GROUP,
+                JsonUtils.objectToJson(newRules));
             client.update(configMap);
         }
 
-        // update theme setting
-        data.put(SystemSetting.ThemeRouteRules.GROUP, JsonUtils.objectToJson(newRouteRules));
-        annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(newRouteRules));
-    }
+        private void reconcileTagsRule(String name) {
+            client.fetch(ConfigMap.class, name).ifPresent(configMap -> {
+                SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap);
+                SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap);
+                final String oldTagsPrefix = oldRules.getTags();
+                if (!StringUtils.equals(oldTagsPrefix, newRules.getTags())) {
+                    oldRules.setTags(newRules.getTags());
+                    updateNewRuleToConfigMap(configMap, oldRules, newRules);
 
-    boolean changePostPatternPrefixIfNecessary(SystemSetting.ThemeRouteRules oldRules,
-        SystemSetting.ThemeRouteRules newRules) {
-        if (StringUtils.isBlank(oldRules.getArchives())
-            || StringUtils.isBlank(newRules.getPost())) {
+                    log.debug("Tags prefix changed from [{}] to [{}].", oldTagsPrefix,
+                        newRules.getTags());
+                    // then publish event
+                    applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
+                        DefaultTemplateEnum.TAGS,
+                        newRules.getTags()));
+                }
+            });
+        }
+
+        private void reconcileCategoriesRule(String name) {
+            client.fetch(ConfigMap.class, name).ifPresent(configMap -> {
+                SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap);
+                SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap);
+                final String oldCategoriesPrefix = oldRules.getCategories();
+                if (!StringUtils.equals(oldCategoriesPrefix, newRules.getCategories())) {
+                    oldRules.setCategories(newRules.getCategories());
+                    updateNewRuleToConfigMap(configMap, oldRules, newRules);
+
+                    log.debug("Categories prefix changed from [{}] to [{}].", oldCategoriesPrefix,
+                        newRules.getCategories());
+                    // categories rule changed
+                    applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
+                        DefaultTemplateEnum.CATEGORIES,
+                        newRules.getCategories()));
+                }
+            });
+        }
+
+        private void reconcilePostRule(String name) {
+            client.fetch(ConfigMap.class, name).ifPresent(configMap -> {
+                SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap);
+                SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap);
+
+                final String oldPostPattern = oldRules.getPost();
+                if (!StringUtils.equals(oldPostPattern, newRules.getPost())) {
+                    oldRules.setPost(newRules.getPost());
+                    updateNewRuleToConfigMap(configMap, oldRules, newRules);
+
+                    log.debug("Categories prefix changed from [{}] to [{}].", oldPostPattern,
+                        newRules.getPost());
+                    // post rule changed
+                    applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
+                        DefaultTemplateEnum.POST,
+                        newRules.getPost()));
+                }
+            });
+        }
+
+        static boolean changePostPatternPrefixIfNecessary(String oldArchivePrefix,
+            SystemSetting.ThemeRouteRules newRules) {
+            if (StringUtils.isBlank(oldArchivePrefix)
+                || StringUtils.isBlank(newRules.getPost())) {
+                return false;
+            }
+            String newArchivesPrefix = newRules.getArchives();
+            if (StringUtils.equals(oldArchivePrefix, newArchivesPrefix)) {
+                return false;
+            }
+
+            String oldPrefix = StringUtils.removeStart(oldArchivePrefix, "/");
+            String postPattern = StringUtils.removeStart(newRules.getPost(), "/");
+
+            if (postPattern.startsWith(oldPrefix)) {
+                String postPatternToUpdate = PathUtils.combinePath(newArchivesPrefix,
+                    StringUtils.removeStart(postPattern, oldPrefix));
+                newRules.setPost(postPatternToUpdate);
+                return true;
+            }
             return false;
         }
-        String oldArchivesPrefix = StringUtils.removeStart(oldRules.getArchives(), "/");
 
-        String postPattern = StringUtils.removeStart(newRules.getPost(), "/");
-        String newArchivesPrefix = newRules.getArchives();
-        if (postPattern.startsWith(oldArchivesPrefix)) {
-            String postPatternToUpdate = PathUtils.combinePath(newArchivesPrefix,
-                StringUtils.removeStart(postPattern, oldArchivesPrefix));
-            newRules.setPost(postPatternToUpdate);
+        private SystemSetting.ThemeRouteRules getOldRouteRulesFromAnno(ConfigMap configMap) {
+            Map<String, String> annotations = getAnnotationsSafe(configMap);
+            String oldRulesJson = annotations.get(OLD_THEME_ROUTE_RULES);
 
-            // post rule changed
-            applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
-                DefaultTemplateEnum.POST, postPatternToUpdate));
-            return true;
+            // old rules is empty, means this is the first time to update theme route rules
+            if (oldRulesJson == null) {
+                oldRulesJson = "{}";
+            }
+
+            // diff old rules and new rules
+            return JsonUtils.jsonToObject(oldRulesJson, SystemSetting.ThemeRouteRules.class);
         }
-        return false;
-    }
 
-    private Map<String, String> getAnnotationsSafe(ConfigMap configMap) {
-        Map<String, String> annotations = configMap.getMetadata().getAnnotations();
-        if (annotations == null) {
-            annotations = new HashMap<>();
-            configMap.getMetadata().setAnnotations(annotations);
+        private SystemSetting.ThemeRouteRules getRouteRules(ConfigMap configMap) {
+            Map<String, String> data = configMap.getData();
+            // get new rules and replace old rules to new rules
+            return JsonUtils.jsonToObject(data.get(SystemSetting.ThemeRouteRules.GROUP),
+                SystemSetting.ThemeRouteRules.class);
+        }
+
+        private Map<String, String> getAnnotationsSafe(ConfigMap configMap) {
+            Map<String, String> annotations = configMap.getMetadata().getAnnotations();
+            if (annotations == null) {
+                annotations = new HashMap<>();
+                configMap.getMetadata().setAnnotations(annotations);
+            }
+            return annotations;
         }
-        return annotations;
     }
 
     public boolean isSystemSetting(String name) {
diff --git a/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java b/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java
index 8ece26e6d..3e698865f 100644
--- a/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java
+++ b/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java
@@ -36,9 +36,13 @@ public class SystemConfigurableEnvironmentFetcher {
 
     @NonNull
     private Mono<Map<String, String>> getValuesInternal() {
-        return extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
+        return getConfigMap()
             .filter(configMap -> configMap.getData() != null)
             .map(ConfigMap::getData)
             .defaultIfEmpty(Map.of());
     }
+
+    public Mono<ConfigMap> getConfigMap() {
+        return extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG);
+    }
 }
diff --git a/src/main/java/run/halo/app/infra/SystemSetting.java b/src/main/java/run/halo/app/infra/SystemSetting.java
index a44d93a7a..2e5e912e1 100644
--- a/src/main/java/run/halo/app/infra/SystemSetting.java
+++ b/src/main/java/run/halo/app/infra/SystemSetting.java
@@ -38,4 +38,44 @@ public class SystemSetting {
 
         private String footer;
     }
+
+    @Data
+    public static class Basic {
+        public static final String GROUP = "basic";
+        String title;
+        String subtitle;
+        String logo;
+        String favicon;
+    }
+
+    @Data
+    public static class User {
+        public static final String GROUP = "user";
+        Boolean allowRegistration;
+        String defaultRole;
+    }
+
+    @Data
+    public static class Post {
+        public static final String GROUP = "post";
+        String sortOrder;
+        Integer pageSize;
+        Boolean review;
+    }
+
+    @Data
+    public static class Seo {
+        public static final String GROUP = "seo";
+        Boolean blockSpiders;
+        String keywords;
+        String description;
+    }
+
+    @Data
+    public static class Comment {
+        public static final String GROUP = "comment";
+        Boolean enable;
+        Boolean requireReviewForNew;
+        Boolean systemUserOnly;
+    }
 }
diff --git a/src/main/java/run/halo/app/theme/HaloViewResolver.java b/src/main/java/run/halo/app/theme/HaloViewResolver.java
index bc7157ad6..5059591a2 100644
--- a/src/main/java/run/halo/app/theme/HaloViewResolver.java
+++ b/src/main/java/run/halo/app/theme/HaloViewResolver.java
@@ -1,14 +1,19 @@
 package run.halo.app.theme;
 
+import java.util.HashMap;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
 import org.springframework.http.MediaType;
+import org.springframework.lang.NonNull;
 import org.springframework.stereotype.Component;
 import org.springframework.web.reactive.result.view.View;
 import org.springframework.web.server.ServerWebExchange;
 import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView;
 import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 import reactor.core.scheduler.Schedulers;
 import run.halo.app.theme.finders.FinderRegistry;
@@ -49,9 +54,51 @@ public class HaloViewResolver extends ThymeleafReactiveViewResolver {
                 // calculate the engine before rendering
                 setTemplateEngine(engineManager.getTemplateEngine(theme));
                 return super.render(model, contentType, exchange)
-                        .subscribeOn(Schedulers.boundedElastic());
+                    .subscribeOn(Schedulers.boundedElastic());
             });
         }
-    }
 
+        @Override
+        @NonNull
+        protected Mono<Map<String, Object>> getModelAttributes(Map<String, ?> model,
+            @NonNull ServerWebExchange exchange) {
+            Mono<Map<String, Object>> contextBasedStaticVariables =
+                getContextBasedStaticVariables(exchange);
+            Mono<Map<String, Object>> modelAttributes = super.getModelAttributes(model, exchange);
+            return Flux.merge(modelAttributes, contextBasedStaticVariables)
+                .collectList()
+                .map(modelMapList -> {
+                    Map<String, Object> result = new HashMap<>();
+                    modelMapList.forEach(result::putAll);
+                    return result;
+                });
+        }
+
+        @NonNull
+        private Mono<Map<String, Object>> getContextBasedStaticVariables(
+            ServerWebExchange exchange) {
+            ApplicationContext applicationContext = obtainApplicationContext();
+
+            return Mono.just(new HashMap<String, Object>())
+                .flatMap(staticVariables -> {
+                    List<Mono<Map<String, Object>>> monoList = applicationContext.getBeansOfType(
+                            ViewContextBasedVariablesAcquirer.class)
+                        .values()
+                        .stream()
+                        .map(acquirer -> acquirer.acquire(exchange))
+                        .toList();
+                    return Flux.merge(monoList)
+                        .collectList()
+                        .map(modelList -> {
+                            Map<String, Object> mergedModel = new HashMap<>();
+                            modelList.forEach(mergedModel::putAll);
+                            return mergedModel;
+                        })
+                        .map(mergedModel -> {
+                            staticVariables.putAll(mergedModel);
+                            return staticVariables;
+                        });
+                });
+        }
+    }
 }
diff --git a/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java b/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java
new file mode 100644
index 000000000..413a57cfa
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java
@@ -0,0 +1,34 @@
+package run.halo.app.theme;
+
+import java.util.Map;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
+import run.halo.app.theme.finders.vo.SiteSettingVo;
+
+/**
+ * Site setting variables acquirer.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class SiteSettingVariablesAcquirer implements ViewContextBasedVariablesAcquirer {
+
+    private final SystemConfigurableEnvironmentFetcher environmentFetcher;
+
+    public SiteSettingVariablesAcquirer(SystemConfigurableEnvironmentFetcher environmentFetcher) {
+        this.environmentFetcher = environmentFetcher;
+    }
+
+    @Override
+    public Mono<Map<String, Object>> acquire(ServerWebExchange exchange) {
+        return environmentFetcher.getConfigMap()
+            .filter(configMap -> configMap.getData() != null)
+            .map(configMap -> {
+                SiteSettingVo siteSettingVo = SiteSettingVo.from(configMap);
+                return Map.of("site", siteSettingVo);
+            });
+    }
+}
diff --git a/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java b/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java
new file mode 100644
index 000000000..151937d5e
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java
@@ -0,0 +1,41 @@
+package run.halo.app.theme;
+
+import java.util.Map;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+import run.halo.app.theme.finders.ThemeFinder;
+import run.halo.app.theme.finders.vo.ThemeVo;
+
+/**
+ * Theme context based variables acquirer.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class ThemeContextBasedVariablesAcquirer implements ViewContextBasedVariablesAcquirer {
+    private final ThemeFinder themeFinder;
+    private final ThemeResolver themeResolver;
+
+    public ThemeContextBasedVariablesAcquirer(ThemeFinder themeFinder,
+        ThemeResolver themeResolver) {
+        this.themeFinder = themeFinder;
+        this.themeResolver = themeResolver;
+    }
+
+    @Override
+    public Mono<Map<String, Object>> acquire(ServerWebExchange exchange) {
+        return themeResolver.getTheme(exchange.getRequest())
+            .publishOn(Schedulers.boundedElastic())
+            .map(themeContext -> {
+                String name = themeContext.getName();
+                ThemeVo themeVo = themeFinder.getByName(name);
+                if (themeVo == null) {
+                    return Map.of();
+                }
+                return Map.of("theme", themeVo);
+            });
+    }
+}
diff --git a/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java b/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java
new file mode 100644
index 000000000..003c307a4
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java
@@ -0,0 +1,11 @@
+package run.halo.app.theme;
+
+import java.util.Map;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+@FunctionalInterface
+public interface ViewContextBasedVariablesAcquirer {
+
+    Mono<Map<String, Object>> acquire(ServerWebExchange exchange);
+}
diff --git a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java
index 7a8cb77dd..8aea154e2 100644
--- a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java
+++ b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java
@@ -13,7 +13,7 @@ import org.thymeleaf.standard.StandardDialect;
  * @since 2.0.0
  */
 public class HaloProcessorDialect extends AbstractProcessorDialect {
-    private static final String DIALECT_NAME = "Halo Theme Dialect";
+    private static final String DIALECT_NAME = "haloThemeProcessorDialect";
 
     public HaloProcessorDialect() {
         // We will set this dialect the same "dialect processor" precedence as
@@ -27,6 +27,7 @@ public class HaloProcessorDialect extends AbstractProcessorDialect {
         // add more processors
         processors.add(new GlobalHeadInjectionProcessor(dialectPrefix));
         processors.add(new TemplateFooterElementTagProcessor(dialectPrefix));
+        processors.add(new JsonNodePropertyAccessorBoundariesProcessor());
         return processors;
     }
 }
diff --git a/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java b/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java
new file mode 100644
index 000000000..3b69a0cdc
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java
@@ -0,0 +1,45 @@
+package run.halo.app.theme.dialect;
+
+import org.springframework.integration.json.JsonPropertyAccessor;
+import org.thymeleaf.context.ITemplateContext;
+import org.thymeleaf.model.ITemplateEnd;
+import org.thymeleaf.model.ITemplateStart;
+import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor;
+import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler;
+import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
+import org.thymeleaf.standard.StandardDialect;
+import org.thymeleaf.templatemode.TemplateMode;
+
+/**
+ * A template boundaries processor for add {@link JsonPropertyAccessor} to
+ * {@link ThymeleafEvaluationContext}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public class JsonNodePropertyAccessorBoundariesProcessor
+    extends AbstractTemplateBoundariesProcessor {
+    private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE;
+    private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor();
+
+    public JsonNodePropertyAccessorBoundariesProcessor() {
+        super(TemplateMode.HTML, PRECEDENCE);
+    }
+
+    @Override
+    public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart,
+        ITemplateBoundariesStructureHandler structureHandler) {
+        ThymeleafEvaluationContext evaluationContext =
+            (ThymeleafEvaluationContext) context.getVariable(
+                ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME);
+        if (evaluationContext != null) {
+            evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR);
+        }
+    }
+
+    @Override
+    public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd,
+        ITemplateBoundariesStructureHandler structureHandler) {
+        // nothing to do
+    }
+}
diff --git a/src/main/java/run/halo/app/theme/finders/ThemeFinder.java b/src/main/java/run/halo/app/theme/finders/ThemeFinder.java
new file mode 100644
index 000000000..aed5db34c
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/finders/ThemeFinder.java
@@ -0,0 +1,16 @@
+package run.halo.app.theme.finders;
+
+import run.halo.app.theme.finders.vo.ThemeVo;
+
+/**
+ * A finder for theme.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public interface ThemeFinder {
+
+    ThemeVo activation();
+
+    ThemeVo getByName(String themeName);
+}
diff --git a/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java
new file mode 100644
index 000000000..aba9b9102
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java
@@ -0,0 +1,68 @@
+package run.halo.app.theme.finders.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.Theme;
+import run.halo.app.extension.ConfigMap;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
+import run.halo.app.infra.SystemSetting;
+import run.halo.app.infra.utils.JsonUtils;
+import run.halo.app.theme.finders.Finder;
+import run.halo.app.theme.finders.ThemeFinder;
+import run.halo.app.theme.finders.vo.ThemeVo;
+
+/**
+ * A default implementation for {@link ThemeFinder}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Finder("themeFinder")
+public class ThemeFinderImpl implements ThemeFinder {
+
+    private final ReactiveExtensionClient client;
+    private final SystemConfigurableEnvironmentFetcher environmentFetcher;
+
+    public ThemeFinderImpl(ReactiveExtensionClient client,
+        SystemConfigurableEnvironmentFetcher environmentFetcher) {
+        this.client = client;
+        this.environmentFetcher = environmentFetcher;
+    }
+
+    @Override
+    public ThemeVo activation() {
+        return environmentFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class)
+            .map(SystemSetting.Theme::getActive)
+            .flatMap(themeName -> client.fetch(Theme.class, themeName))
+            .flatMap(theme -> themeWithConfig(ThemeVo.from(theme)))
+            .block();
+    }
+
+    @Override
+    public ThemeVo getByName(String themeName) {
+        return client.fetch(Theme.class, themeName)
+            .flatMap(theme -> themeWithConfig(ThemeVo.from(theme)))
+            .block();
+    }
+
+    private Mono<ThemeVo> themeWithConfig(ThemeVo themeVo) {
+        if (StringUtils.isBlank(themeVo.getSpec().getConfigMapName())) {
+            return Mono.just(themeVo);
+        }
+        return client.fetch(ConfigMap.class, themeVo.getSpec().getConfigMapName())
+            .map(configMap -> {
+                Map<String, JsonNode> config = new HashMap<>();
+                configMap.getData().forEach((k, v) -> {
+                    JsonNode jsonNode = JsonUtils.jsonToObject(v, JsonNode.class);
+                    config.put(k, jsonNode);
+                });
+                JsonNode configJson = JsonUtils.mapToObject(config, JsonNode.class);
+                return themeVo.withConfig(configJson);
+            })
+            .switchIfEmpty(Mono.just(themeVo));
+    }
+}
diff --git a/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java b/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java
new file mode 100644
index 000000000..0e7684912
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java
@@ -0,0 +1,121 @@
+package run.halo.app.theme.finders.vo;
+
+import java.util.Map;
+import lombok.Builder;
+import lombok.Value;
+import org.springframework.util.Assert;
+import run.halo.app.extension.ConfigMap;
+import run.halo.app.infra.SystemSetting;
+import run.halo.app.infra.utils.JsonUtils;
+
+/**
+ * Site setting value object for theme.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Value
+@Builder
+public class SiteSettingVo {
+
+    String title;
+
+    String subtitle;
+
+    String logo;
+
+    String favicon;
+
+    Boolean allowRegistration;
+
+    PostSetting post;
+
+    SeoSetting seo;
+
+    CommentSetting comment;
+
+    /**
+     * Convert to system {@link ConfigMap} to {@link SiteSettingVo}.
+     *
+     * @param configMap config map named system
+     * @return site setting value object
+     */
+    public static SiteSettingVo from(ConfigMap configMap) {
+        Assert.notNull(configMap, "The configMap must not be null.");
+        Map<String, String> data = configMap.getData();
+        if (data == null) {
+            return builder().build();
+        }
+        SystemSetting.Basic basicSetting =
+            toObject(data.get(SystemSetting.Basic.GROUP), SystemSetting.Basic.class);
+
+        SystemSetting.User userSetting =
+            toObject(data.get(SystemSetting.User.GROUP), SystemSetting.User.class);
+
+        SystemSetting.Post postSetting =
+            toObject(data.get(SystemSetting.Post.GROUP), SystemSetting.Post.class);
+
+        SystemSetting.Seo seoSetting =
+            toObject(data.get(SystemSetting.Seo.GROUP), SystemSetting.Seo.class);
+
+        SystemSetting.Comment commentSetting = toObject(data.get(SystemSetting.Comment.GROUP),
+            SystemSetting.Comment.class);
+        return builder()
+            .title(basicSetting.getTitle())
+            .subtitle(basicSetting.getSubtitle())
+            .logo(basicSetting.getLogo())
+            .favicon(basicSetting.getFavicon())
+            .allowRegistration(userSetting.getAllowRegistration())
+            .post(PostSetting.builder()
+                .sortOrder(postSetting.getSortOrder())
+                .pageSize(postSetting.getPageSize())
+                .build())
+            .seo(SeoSetting.builder()
+                .blockSpiders(seoSetting.getBlockSpiders())
+                .keywords(seoSetting.getKeywords())
+                .description(seoSetting.getDescription())
+                .build())
+            .comment(CommentSetting.builder()
+                .enable(commentSetting.getEnable())
+                .requireReviewForNew(commentSetting.getRequireReviewForNew())
+                .systemUserOnly(commentSetting.getSystemUserOnly())
+                .build())
+            .build();
+    }
+
+    private static <T> T toObject(String json, Class<T> type) {
+        if (json == null) {
+            // empty object
+            json = "{}";
+        }
+        return JsonUtils.jsonToObject(json, type);
+    }
+
+    @Value
+    @Builder
+    public static class PostSetting {
+        String sortOrder;
+
+        Integer pageSize;
+    }
+
+    @Value
+    @Builder
+    public static class SeoSetting {
+        Boolean blockSpiders;
+
+        String keywords;
+
+        String description;
+    }
+
+    @Value
+    @Builder
+    public static class CommentSetting {
+        Boolean enable;
+
+        Boolean systemUserOnly;
+
+        Boolean requireReviewForNew;
+    }
+}
diff --git a/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java b/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java
new file mode 100644
index 000000000..b6725ef0b
--- /dev/null
+++ b/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java
@@ -0,0 +1,42 @@
+package run.halo.app.theme.finders.vo;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.Builder;
+import lombok.ToString;
+import lombok.Value;
+import lombok.With;
+import run.halo.app.core.extension.Theme;
+import run.halo.app.extension.MetadataOperator;
+
+/**
+ * A value object for {@link Theme}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Value
+@Builder
+@ToString
+public class ThemeVo {
+
+    MetadataOperator metadata;
+
+    Theme.ThemeSpec spec;
+
+    @With
+    JsonNode config;
+
+    /**
+     * Convert {@link Theme} to {@link ThemeVo}.
+     *
+     * @param theme theme extension
+     * @return theme value object
+     */
+    public static ThemeVo from(Theme theme) {
+        return ThemeVo.builder()
+            .metadata(theme.getMetadata())
+            .spec(theme.getSpec())
+            .config(null)
+            .build();
+    }
+}
diff --git a/src/main/resources/extensions/system-setting.yaml b/src/main/resources/extensions/system-setting.yaml
index e8e073b73..2b72192a6 100644
--- a/src/main/resources/extensions/system-setting.yaml
+++ b/src/main/resources/extensions/system-setting.yaml
@@ -91,7 +91,7 @@ spec:
     label: SEO 设置
     formSchema:
       - $formkit: checkbox
-        name: blockSpides
+        name: blockSpiders
         label: "屏蔽搜索引擎"
         value: false
       - $formkit: textarea
diff --git a/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java
index 64e1d0d1c..3a6b0d3e2 100644
--- a/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java
+++ b/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java
@@ -1,15 +1,30 @@
 package run.halo.app.core.extension.reconciler;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.context.ApplicationContext;
+import run.halo.app.extension.ConfigMap;
 import run.halo.app.extension.ExtensionClient;
+import run.halo.app.extension.Metadata;
+import run.halo.app.extension.controller.Reconciler;
 import run.halo.app.infra.SystemSetting;
+import run.halo.app.infra.utils.JsonUtils;
 
 /**
  * Tests for {@link SystemSettingReconciler}.
@@ -34,16 +49,133 @@ class SystemSettingReconcilerTest {
     }
 
     @Test
-    void changePostPatternPrefixIfNecessary() {
-        SystemSetting.ThemeRouteRules oldRouteRules = new SystemSetting.ThemeRouteRules();
-        oldRouteRules.setPost("/archives/{slug}");
-        oldRouteRules.setArchives("archives");
+    void reconcileArchivesRouteRule() {
+        ConfigMap configMap = systemConfigMapForRouteRule(rules -> {
+            rules.setArchives("archives-new");
+            return rules;
+        });
+        when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)))
+            .thenReturn(Optional.of(configMap));
+        systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG));
+        ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class);
+        verify(client, times(2)).update(captor.capture());
 
+        List<ConfigMap> allValues = captor.getAllValues();
+        ConfigMap updatedConfigMap = allValues.get(1);
+        assertThat(rulesFrom(updatedConfigMap).getArchives()).isEqualTo("archives-new");
+        assertThat(rulesFrom(updatedConfigMap).getPost()).isEqualTo("/archives-new/{slug}");
+
+        assertThat(oldRulesFromAnno(updatedConfigMap).getArchives()).isEqualTo("archives-new");
+        assertThat(oldRulesFromAnno(updatedConfigMap).getPost()).isEqualTo("/archives-new/{slug}");
+
+        // archives and post
+        verify(applicationContext, times(2)).publishEvent(any());
+    }
+
+    @Test
+    void reconcileTagsRule() {
+        ConfigMap configMap = systemConfigMapForRouteRule(rules -> {
+            rules.setTags("tags-new");
+            return rules;
+        });
+        when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)))
+            .thenReturn(Optional.of(configMap));
+        systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG));
+        ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class);
+        verify(client, times(2)).update(captor.capture());
+
+        List<ConfigMap> allValues = captor.getAllValues();
+        ConfigMap updatedConfigMap = allValues.get(1);
+        assertThat(rulesFrom(updatedConfigMap).getTags()).isEqualTo("tags-new");
+
+        assertThat(oldRulesFromAnno(updatedConfigMap).getTags()).isEqualTo("tags-new");
+
+        verify(applicationContext, times(1)).publishEvent(any());
+    }
+
+    @Test
+    void reconcileCategoriesRule() {
+        ConfigMap configMap = systemConfigMapForRouteRule(rules -> {
+            rules.setCategories("categories-new");
+            return rules;
+        });
+        when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)))
+            .thenReturn(Optional.of(configMap));
+        systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG));
+        ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class);
+        verify(client, times(2)).update(captor.capture());
+
+        List<ConfigMap> allValues = captor.getAllValues();
+        ConfigMap updatedConfigMap = allValues.get(1);
+        assertThat(rulesFrom(updatedConfigMap).getCategories()).isEqualTo("categories-new");
+
+        assertThat(oldRulesFromAnno(updatedConfigMap).getCategories()).isEqualTo("categories-new");
+
+        verify(applicationContext, times(1)).publishEvent(any());
+    }
+
+    @Test
+    void reconcilePostRule() {
+        ConfigMap configMap = systemConfigMapForRouteRule(rules -> {
+            rules.setPost("/post-new/{slug}");
+            return rules;
+        });
+        when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)))
+            .thenReturn(Optional.of(configMap));
+        systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG));
+        ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class);
+        verify(client, times(2)).update(captor.capture());
+
+        List<ConfigMap> allValues = captor.getAllValues();
+        ConfigMap updatedConfigMap = allValues.get(1);
+        assertThat(rulesFrom(updatedConfigMap).getPost()).isEqualTo("/post-new/{slug}");
+
+        assertThat(oldRulesFromAnno(updatedConfigMap).getPost()).isEqualTo("/post-new/{slug}");
+
+        verify(applicationContext, times(1)).publishEvent(any());
+    }
+
+    private SystemSetting.ThemeRouteRules rulesFrom(ConfigMap configMap) {
+        String s = configMap.getData().get(SystemSetting.ThemeRouteRules.GROUP);
+        return JsonUtils.jsonToObject(s, SystemSetting.ThemeRouteRules.class);
+    }
+
+    private SystemSetting.ThemeRouteRules oldRulesFromAnno(ConfigMap configMap) {
+        Map<String, String> annotations = configMap.getMetadata().getAnnotations();
+        String s = annotations.get(SystemSettingReconciler.OLD_THEME_ROUTE_RULES);
+        return JsonUtils.jsonToObject(s, SystemSetting.ThemeRouteRules.class);
+    }
+
+    private ConfigMap systemConfigMapForRouteRule(
+        Function<SystemSetting.ThemeRouteRules, SystemSetting.ThemeRouteRules> function) {
+        ConfigMap configMap = new ConfigMap();
+        Metadata metadata = new Metadata();
+        metadata.setName(SystemSetting.SYSTEM_CONFIG);
+        configMap.setMetadata(metadata);
+
+        SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules();
+        themeRouteRules.setArchives("archives");
+        themeRouteRules.setTags("tags");
+        themeRouteRules.setCategories("categories");
+        themeRouteRules.setPost("/archives/{slug}");
+        Map<String, String> annotations = new HashMap<>();
+        annotations.put(SystemSettingReconciler.OLD_THEME_ROUTE_RULES,
+            JsonUtils.objectToJson(themeRouteRules));
+        metadata.setAnnotations(annotations);
+
+        SystemSetting.ThemeRouteRules newRules = function.apply(themeRouteRules);
+        configMap.putDataItem(SystemSetting.ThemeRouteRules.GROUP,
+            JsonUtils.objectToJson(newRules));
+        return configMap;
+    }
+
+    @Test
+    void changePostPatternPrefixIfNecessary() {
         SystemSetting.ThemeRouteRules newRouteRules = new SystemSetting.ThemeRouteRules();
         newRouteRules.setPost("/archives/{slug}");
         newRouteRules.setArchives("new");
-        boolean result = systemSettingReconciler.changePostPatternPrefixIfNecessary(oldRouteRules,
-            newRouteRules);
+        boolean result = SystemSettingReconciler.RouteRuleReconciler
+            .changePostPatternPrefixIfNecessary("archives", newRouteRules);
         assertThat(result).isTrue();
 
         assertThat(newRouteRules.getPost()).isEqualTo("/new/{slug}");
diff --git a/src/test/java/run/halo/app/theme/dialect/ThemeJava8TimeDialectIntegrationTest.java b/src/test/java/run/halo/app/theme/dialect/ThemeJava8TimeDialectIntegrationTest.java
index f90c86cd1..64374da63 100644
--- a/src/test/java/run/halo/app/theme/dialect/ThemeJava8TimeDialectIntegrationTest.java
+++ b/src/test/java/run/halo/app/theme/dialect/ThemeJava8TimeDialectIntegrationTest.java
@@ -13,7 +13,6 @@ import java.time.ZoneId;
 import java.util.Locale;
 import java.util.Map;
 import java.util.TimeZone;
-import java.util.function.Function;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -53,8 +52,6 @@ class ThemeJava8TimeDialectIntegrationTest {
 
     private URL defaultThemeUrl;
 
-    Function<ServerHttpRequest, ThemeContext> themeContextFunction;
-
     @Autowired
     private WebTestClient webTestClient;
 
@@ -63,8 +60,8 @@ class ThemeJava8TimeDialectIntegrationTest {
     @BeforeEach
     void setUp() throws FileNotFoundException {
         defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
-        when(themeResolver.getTheme(any(ServerHttpRequest.class))).thenReturn(
-            Mono.just(createDefaultContext()));
+        when(themeResolver.getTheme(any(ServerHttpRequest.class)))
+            .thenReturn(Mono.just(createDefaultContext()));
         defaultTimeZone = TimeZone.getDefault();
     }