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 7a167176a7..bb9798b6ac 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 @@ /** * 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 55c89c665a..9da5479543 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.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 @@ * @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 0000000000..6090413727 --- /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 4016ef983a..b4be9c7a7d 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 @@ 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 @@ protected void doProcess(ITemplateContext context, IModel model, 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 @@ protected void doProcess(ITemplateContext context, IModel model, .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 afacfa03e3..1ca3c667da 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 @@ 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 @@ * @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 3cf660acf3..030ac63e21 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 @@ /** *

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 0000000000..6af24a1350 --- /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 18367c347b..b23e59587f 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 @@ void contentHeadAndFooterAndPostProcessors() { Post - + + + - -

post

@@ -203,9 +203,9 @@ void blockSeo() { Seo Test - + + - seo setting test. @@ -234,10 +234,10 @@ void seoWithKeywordsAndDescription() { 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 2ce586c917..aed217a102 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 @@ void messageResolverWhenDefaultTheme() { .isEqualTo(""" - - - Title - + Title index
zh
@@ -96,10 +93,7 @@ void messageResolverForEnLanguageWhenDefaultTheme() { .isEqualTo(""" - - - Title - + Title index
en
@@ -120,10 +114,7 @@ void shouldUseDefaultWhenLanguageNotSupport() { .isEqualTo(""" - - - Title - + Title index
foo
@@ -144,10 +135,7 @@ void switchTheme() throws URISyntaxException { .isEqualTo(""" - - - Title - + Title index
zh
@@ -166,10 +154,7 @@ void switchTheme() throws URISyntaxException { .isEqualTo(""" - - - Other theme title - + Other theme title

Other 首页

@@ -182,10 +167,7 @@ void switchTheme() throws URISyntaxException { .isEqualTo(""" - - - Other theme title - + Other theme title

other index