From 972ebed03a70d9799aaa09f9dbc9ebf5f5ee1c92 Mon Sep 17 00:00:00 2001
From: guqing <38999863+guqing@users.noreply.github.com>
Date: Wed, 28 Jun 2023 22:54:12 +0800
Subject: [PATCH] refactor: content page meta tags now override global injected
(#4069)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#### What type of PR is this?
/kind improvement
/area core
/milestone 2.7.x
#### What this PR does / why we need it:
修复文章页 HTML Meta 标签重复问题
see #4049 for more details.
#### Which issue(s) this PR fixes:
Fixes #4049
#### Does this PR introduce a user-facing change?
```release-note
修复文章页 Meta Description 标签重复问题
```
---
.../theme/dialect/TemplateHeadProcessor.java | 3 +
.../dialect/ContentTemplateHeadProcessor.java | 2 +
.../dialect/DuplicateMetaTagProcessor.java | 78 +++++++
.../dialect/GlobalHeadInjectionProcessor.java | 28 ++-
.../app/theme/dialect/GlobalSeoProcessor.java | 3 +-
.../dialect/TemplateGlobalHeadProcessor.java | 4 +
...tTemplateHeadProcessorIntegrationTest.java | 196 ++++++++++++++++++
.../dialect/HaloProcessorDialectTest.java | 16 +-
.../ThemeMessageResolverIntegrationTest.java | 30 +--
9 files changed, 321 insertions(+), 39 deletions(-)
create mode 100644 application/src/main/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessor.java
create mode 100644 application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java
diff --git a/api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java b/api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java
index 7a167176a..bb9798b6a 100644
--- a/api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java
+++ b/api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java
@@ -8,6 +8,9 @@ import reactor.core.publisher.Mono;
/**
* Theme template head
tag snippet injection processor.
+ *
Head processor is processed order by {@link org.springframework.core.annotation.Order}
+ * annotation, Higher order will be processed first and so that low-priority processor can be
+ * overwritten head tag written by high-priority processor.
*
* @author guqing
* @since 2.0.0
diff --git a/application/src/main/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessor.java
index 55c89c665..9da547954 100644
--- a/application/src/main/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessor.java
+++ b/application/src/main/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessor.java
@@ -9,6 +9,7 @@ import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
+import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.util.HtmlUtils;
import org.thymeleaf.context.ITemplateContext;
@@ -29,6 +30,7 @@ import run.halo.app.theme.router.ModelConst;
* @since 2.0.0
*/
@Component
+@Order(1)
@AllArgsConstructor
public class ContentTemplateHeadProcessor implements TemplateHeadProcessor {
private static final String POST_NAME_VARIABLE = "name";
diff --git a/application/src/main/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessor.java
new file mode 100644
index 000000000..609041372
--- /dev/null
+++ b/application/src/main/java/run/halo/app/theme/dialect/DuplicateMetaTagProcessor.java
@@ -0,0 +1,78 @@
+package run.halo.app.theme.dialect;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import lombok.AllArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.thymeleaf.context.ITemplateContext;
+import org.thymeleaf.model.IModel;
+import org.thymeleaf.model.ITemplateEvent;
+import org.thymeleaf.model.IText;
+import org.thymeleaf.processor.element.IElementModelStructureHandler;
+import reactor.core.publisher.Mono;
+
+/**
+ * This processor will remove the duplicate meta tag with the same name in head tag and only
+ * keep the last one.
+ * This processor will be executed last.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Order
+@Component
+@AllArgsConstructor
+public class DuplicateMetaTagProcessor implements TemplateHeadProcessor {
+ static final Pattern META_PATTERN = Pattern.compile(" ");
+
+ @Override
+ public Mono process(ITemplateContext context, IModel model,
+ IElementModelStructureHandler structureHandler) {
+ IModel newModel = context.getModelFactory().createModel();
+
+ Map uniqueMetaTags = new LinkedHashMap<>();
+ List otherModel = new ArrayList<>();
+ for (int i = 0; i < model.size(); i++) {
+ ITemplateEvent templateEvent = model.get(i);
+ // If the current node is a text node, it is processed separately.
+ // Because the text node may contain multiple meta tags.
+ if (templateEvent instanceof IText textNode) {
+ String text = textNode.getText();
+ Matcher matcher = META_PATTERN.matcher(text);
+ while (matcher.find()) {
+ String tagLine = matcher.group(0);
+ String nameAttribute = matcher.group(1);
+ IText metaTagNode = context.getModelFactory().createText(tagLine);
+ uniqueMetaTags.put(nameAttribute, new IndexedModel(i, metaTagNode));
+ text = text.replace(tagLine, "");
+ }
+ if (StringUtils.isNotBlank(text)) {
+ IText otherText = context.getModelFactory()
+ .createText(text);
+ otherModel.add(new IndexedModel(i, otherText));
+ }
+ } else {
+ otherModel.add(new IndexedModel(i, templateEvent));
+ }
+ }
+
+ otherModel.addAll(uniqueMetaTags.values());
+ otherModel.stream().sorted(Comparator.comparing(IndexedModel::index))
+ .map(IndexedModel::templateEvent)
+ .forEach(newModel::add);
+
+ model.reset();
+ model.addModel(newModel);
+ return Mono.empty();
+ }
+
+ record IndexedModel(int index, ITemplateEvent templateEvent) {
+ }
+}
diff --git a/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java
index 4016ef983..b4be9c7a7 100644
--- a/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java
+++ b/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java
@@ -2,9 +2,10 @@ package run.halo.app.theme.dialect;
import java.util.Collection;
import org.springframework.context.ApplicationContext;
+import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
-import org.thymeleaf.model.IModelFactory;
+import org.thymeleaf.model.ITemplateEvent;
import org.thymeleaf.processor.element.AbstractElementModelProcessor;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import org.thymeleaf.spring6.context.SpringContextUtils;
@@ -51,14 +52,23 @@ public class GlobalHeadInjectionProcessor extends AbstractElementModelProcessor
structureHandler.setLocalVariable(PROCESS_FLAG, true);
// handle tag
+ if (model.size() < 2) {
+ return;
+ }
/*
* Create the DOM structure that will be substituting our custom tag.
* The headline will be shown inside a '' tag, and so this must
* be created first and then a Text node must be added to it.
*/
- final IModelFactory modelFactory = context.getModelFactory();
- IModel modelToInsert = modelFactory.createModel();
+ IModel modelToInsert = model.cloneModel();
+ // close tag
+ final ITemplateEvent closeHeadTag = modelToInsert.get(modelToInsert.size() - 1);
+ modelToInsert.remove(modelToInsert.size() - 1);
+
+ // open tag
+ final ITemplateEvent openHeadTag = modelToInsert.get(0);
+ modelToInsert.remove(0);
// apply processors to modelToInsert
Collection
templateHeadProcessors =
@@ -69,14 +79,20 @@ public class GlobalHeadInjectionProcessor extends AbstractElementModelProcessor
.block();
}
- // add to target model
- model.insertModel(model.size() - 1, modelToInsert);
+ // reset model to insert
+ model.reset();
+ model.add(openHeadTag);
+ model.addModel(modelToInsert);
+ model.add(closeHeadTag);
}
private Collection getTemplateHeadProcessors(ITemplateContext context) {
ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context);
ExtensionComponentsFinder componentsFinder =
appCtx.getBean(ExtensionComponentsFinder.class);
- return componentsFinder.getExtensions(TemplateHeadProcessor.class);
+ return componentsFinder.getExtensions(TemplateHeadProcessor.class)
+ .stream()
+ .sorted(AnnotationAwareOrderComparator.INSTANCE)
+ .toList();
}
}
diff --git a/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java
index afacfa03e..1ca3c667d 100644
--- a/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java
+++ b/application/src/main/java/run/halo/app/theme/dialect/GlobalSeoProcessor.java
@@ -3,6 +3,7 @@ package run.halo.app.theme.dialect;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
+import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.ITemplateContext;
@@ -20,7 +21,7 @@ import run.halo.app.infra.SystemSetting;
* @see SystemSetting.Seo
* @since 2.0.0
*/
-@Order
+@Order(Ordered.HIGHEST_PRECEDENCE + 1)
@Component
@AllArgsConstructor
public class GlobalSeoProcessor implements TemplateHeadProcessor {
diff --git a/application/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java
index 3cf660acf..030ac63e2 100644
--- a/application/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java
+++ b/application/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java
@@ -1,6 +1,8 @@
package run.halo.app.theme.dialect;
import org.apache.commons.lang3.StringUtils;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
@@ -14,10 +16,12 @@ import run.halo.app.theme.router.ModelConst;
/**
* Global custom head snippet injection for theme global setting.
+ * Globally injected head snippet can be overridden by content template.
*
* @author guqing
* @since 2.0.0
*/
+@Order(Ordered.HIGHEST_PRECEDENCE + 2)
@Component
public class TemplateGlobalHeadProcessor implements TemplateHeadProcessor {
diff --git a/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java b/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java
new file mode 100644
index 000000000..6af24a135
--- /dev/null
+++ b/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java
@@ -0,0 +1,196 @@
+package run.halo.app.theme.dialect;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.jsoup.Jsoup;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationContext;
+import org.thymeleaf.IEngineConfiguration;
+import org.thymeleaf.TemplateEngine;
+import org.thymeleaf.context.Context;
+import org.thymeleaf.spring6.dialect.SpringStandardDialect;
+import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
+import org.thymeleaf.templateresolver.StringTemplateResolver;
+import org.thymeleaf.templateresource.ITemplateResource;
+import org.thymeleaf.templateresource.StringTemplateResource;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.content.Post;
+import run.halo.app.extension.Metadata;
+import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
+import run.halo.app.infra.SystemSetting;
+import run.halo.app.plugin.ExtensionComponentsFinder;
+import run.halo.app.theme.DefaultTemplateEnum;
+import run.halo.app.theme.finders.PostFinder;
+import run.halo.app.theme.finders.SinglePageFinder;
+import run.halo.app.theme.finders.vo.PostVo;
+import run.halo.app.theme.router.ModelConst;
+
+/**
+ * Integration tests for {@link ContentTemplateHeadProcessor}.
+ *
+ * @author guqing
+ * @see HaloProcessorDialect
+ * @see GlobalHeadInjectionProcessor
+ * @see ContentTemplateHeadProcessor
+ * @see TemplateHeadProcessor
+ * @see TemplateGlobalHeadProcessor
+ * @see TemplateFooterElementTagProcessor
+ * @since 2.7.0
+ */
+@ExtendWith(MockitoExtension.class)
+class ContentTemplateHeadProcessorIntegrationTest {
+ @Mock
+ private ApplicationContext applicationContext;
+
+ @Mock
+ private PostFinder postFinder;
+
+ @Mock
+ private SinglePageFinder singlePageFinder;
+
+ @Mock
+ private SystemConfigurableEnvironmentFetcher fetcher;
+
+ @Mock
+ private ExtensionComponentsFinder extensionComponentsFinder;
+
+ private TemplateEngine templateEngine;
+
+ @BeforeEach
+ void setUp() {
+ HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect();
+ templateEngine = new TemplateEngine();
+ templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect()));
+ templateEngine.addTemplateResolver(new TestTemplateResolver());
+
+ Map map = new HashMap<>();
+ map.put("postTemplateHeadProcessor",
+ new ContentTemplateHeadProcessor(postFinder, singlePageFinder));
+ map.put("templateGlobalHeadProcessor", new TemplateGlobalHeadProcessor(fetcher));
+ map.put("seoProcessor", new GlobalSeoProcessor(fetcher));
+ map.put("duplicateMetaTagProcessor", new DuplicateMetaTagProcessor());
+ lenient().when(applicationContext.getBeansOfType(eq(TemplateHeadProcessor.class)))
+ .thenReturn(map);
+
+ SystemSetting.Seo seo = new SystemSetting.Seo();
+ seo.setKeywords("global keywords");
+ seo.setDescription("global description");
+ lenient().when(fetcher.fetch(eq(SystemSetting.Seo.GROUP), eq(SystemSetting.Seo.class)))
+ .thenReturn(Mono.just(seo));
+
+ SystemSetting.CodeInjection codeInjection = new SystemSetting.CodeInjection();
+ codeInjection.setGlobalHead(
+ " ");
+ codeInjection.setContentHead(
+ " ");
+ lenient().when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP),
+ eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection));
+
+ lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class)))
+ .thenReturn(fetcher);
+ lenient().when(fetcher.fetch(eq(SystemSetting.Seo.GROUP), eq(SystemSetting.Seo.class)))
+ .thenReturn(Mono.empty());
+
+ lenient().when(applicationContext.getBean(eq(ExtensionComponentsFinder.class)))
+ .thenReturn(extensionComponentsFinder);
+
+ lenient().when(extensionComponentsFinder.getExtensions(eq(TemplateHeadProcessor.class)))
+ .thenReturn(new ArrayList<>(map.values()));
+ }
+
+
+ @Test
+ void overrideGlobalMetaTest() {
+ Context context = getContext();
+ context.setVariable("name", "fake-post");
+ // template id flag is used by TemplateGlobalHeadProcessor
+ context.setVariable(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.POST.getValue());
+
+ List> htmlMetas = new ArrayList<>();
+ htmlMetas.add(mutableMetaMap("keyword", "postK1,postK2"));
+ htmlMetas.add(mutableMetaMap("description", "post-description"));
+ htmlMetas.add(mutableMetaMap("other", "post-other-meta"));
+ Post.PostSpec postSpec = new Post.PostSpec();
+ postSpec.setHtmlMetas(htmlMetas);
+ Metadata metadata = new Metadata();
+ metadata.setName("fake-post");
+ PostVo postVo = PostVo.builder().spec(postSpec).metadata(metadata).build();
+ when(postFinder.getByName(eq("fake-post"))).thenReturn(Mono.just(postVo));
+
+ String result = templateEngine.process("post", context);
+ /*
+ this test case shows:
+ 1. global seo meta keywords and description is overridden by content head meta
+ 2. global head meta is overridden by content head meta
+ 3. but global head meta is not overridden by global seo meta
+ */
+ assertThat(Jsoup.parse(result).html()).isEqualTo("""
+
+
+
+
+ Post detail
+
+
+
+
+
+ this is body
+
+ """);
+ }
+
+ Map mutableMetaMap(String nameValue, String contentValue) {
+ Map map = new HashMap<>();
+ map.put("name", nameValue);
+ map.put("content", contentValue);
+ return map;
+ }
+
+ private Context getContext() {
+ Context context = new Context();
+ context.setVariable(
+ ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME,
+ new ThymeleafEvaluationContext(applicationContext, null));
+ return context;
+ }
+
+ static class TestTemplateResolver extends StringTemplateResolver {
+ @Override
+ protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
+ String ownerTemplate, String template,
+ Map templateResolutionAttributes) {
+ if (template.equals("post")) {
+ return new StringTemplateResource(postTemplate());
+ }
+ return null;
+ }
+
+ private String postTemplate() {
+ return """
+
+
+
+
+ Post detail
+
+
+ this is body
+
+
+ """;
+ }
+ }
+}
diff --git a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java
index 18367c347..b23e59587 100644
--- a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java
+++ b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java
@@ -167,11 +167,11 @@ class HaloProcessorDialectTest {
Post
-
+
+
+
-
-
post
@@ -203,9 +203,9 @@ class HaloProcessorDialectTest {
Seo Test
-
+
+
-
seo setting test.
@@ -234,10 +234,10 @@ class HaloProcessorDialectTest {
Seo Test
-
-
-
+
+
+
seo setting test.
diff --git a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java
index 2ce586c91..aed217a10 100644
--- a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java
+++ b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java
@@ -72,10 +72,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
-
-
- Title
-
+ Title
index
zh
@@ -96,10 +93,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
-
-
- Title
-
+ Title
index
en
@@ -120,10 +114,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
-
-
- Title
-
+ Title
index
foo
@@ -144,10 +135,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
-
-
- Title
-
+ Title
index
zh
@@ -166,10 +154,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
-
-
- Other theme title
-
+ Other theme title
Other 首页
@@ -182,10 +167,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
-
-
- Other theme title
-
+ Other theme title
other index