refactor: content page meta tags now override global injected (#4069)

#### 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 标签重复问题
```
pull/4080/head
guqing 2023-06-28 22:54:12 +08:00 committed by GitHub
parent 8db4cec91e
commit 972ebed03a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 321 additions and 39 deletions

View File

@ -8,6 +8,9 @@ import reactor.core.publisher.Mono;
/**
* Theme template <code>head</code> tag snippet injection processor.
* <p>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.</p>
*
* @author guqing
* @since 2.0.0

View File

@ -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";

View File

@ -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;
/**
* <p>This processor will remove the duplicate meta tag with the same name in head tag and only
* keep the last one.</p>
* <p>This processor will be executed last.</p>
*
* @author guqing
* @since 2.0.0
*/
@Order
@Component
@AllArgsConstructor
public class DuplicateMetaTagProcessor implements TemplateHeadProcessor {
static final Pattern META_PATTERN = Pattern.compile("<meta\\s+name=\"(\\w+)\"(.*?)>");
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
IModel newModel = context.getModelFactory().createModel();
Map<String, IndexedModel> uniqueMetaTags = new LinkedHashMap<>();
List<IndexedModel> 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) {
}
}

View File

@ -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 <head> tag
if (model.size() < 2) {
return;
}
/*
* Create the DOM structure that will be substituting our custom tag.
* The headline will be shown inside a '<div>' 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 </head> tag
final ITemplateEvent closeHeadTag = modelToInsert.get(modelToInsert.size() - 1);
modelToInsert.remove(modelToInsert.size() - 1);
// open <head> tag
final ITemplateEvent openHeadTag = modelToInsert.get(0);
modelToInsert.remove(0);
// apply processors to modelToInsert
Collection<TemplateHeadProcessor> 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<TemplateHeadProcessor> 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();
}
}

View File

@ -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 {

View File

@ -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;
/**
* <p>Global custom head snippet injection for theme global setting.</p>
* <p>Globally injected head snippet can be overridden by content template.</p>
*
* @author guqing
* @since 2.0.0
*/
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
@Component
public class TemplateGlobalHeadProcessor implements TemplateHeadProcessor {

View File

@ -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<String, TemplateHeadProcessor> 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(
"<meta name=\"description\" content=\"global-head-description\"/>");
codeInjection.setContentHead(
"<meta name=\"description\" content=\"content-head-description\"/>");
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<Map<String, String>> 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("""
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Post detail</title>
<meta name="description" content="post-description">
<meta name="keyword" content="postK1,postK2">
<meta name="other" content="post-other-meta">
</head>
<body>
this is body
</body>
</html>""");
}
Map<String, String> mutableMetaMap(String nameValue, String contentValue) {
Map<String, String> 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<String, Object> templateResolutionAttributes) {
if (template.equals("post")) {
return new StringTemplateResource(postTemplate());
}
return null;
}
private String postTemplate() {
return """
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>Post detail</title>
</head>
<body>
this is body
</body>
</html>
""";
}
}
}

View File

@ -167,11 +167,11 @@ class HaloProcessorDialectTest {
<head>
<meta charset="UTF-8" />
<title>Post</title>
<meta content="post-meta-V1" name="post-meta-V1" />
<meta name="global-head-test" content="test" />
<meta name="content-head-test" content="test" />
<meta content="post-meta-V1" name="post-meta-V1" />
<meta content="post-meta-V2" name="post-meta-V2" />
<meta name="description" content="" />
<meta name="global-head-test" content="test" />
<meta name="content-head-test" content="test" />
</head>
<body>
<p>post</p>
@ -203,9 +203,9 @@ class HaloProcessorDialectTest {
<head>
<meta charset="UTF-8" />
<title>Seo Test</title>
<meta name="global-head-test" content="test" />
<meta name="robots" content="noindex" />
<meta name="global-head-test" content="test" />
<link rel="icon" href="favicon.ico" />
<meta name="robots" content="noindex" />
</head>
<body>
seo setting test.
@ -234,10 +234,10 @@ class HaloProcessorDialectTest {
<head>
<meta charset="UTF-8" />
<title>Seo Test</title>
<meta name="global-head-test" content="test" />
<link rel="icon" href="favicon.ico" />
<meta name="keywords" content="K1, K2, K3" />
<meta name="keywords" content="K1, K2, K3" />
<meta name="description" content="This is a description." />
<meta name="global-head-test" content="test" />
<link rel="icon" href="favicon.ico" />
</head>
<body>
seo setting test.

View File

@ -72,10 +72,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<head><meta charset="UTF-8"><title>Title</title></head>
<body>
index
<div>zh</div>
@ -96,10 +93,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<head><meta charset="UTF-8"><title>Title</title></head>
<body>
index
<div>en</div>
@ -120,10 +114,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<head><meta charset="UTF-8"><title>Title</title></head>
<body>
index
<div>foo</div>
@ -144,10 +135,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<head><meta charset="UTF-8"><title>Title</title></head>
<body>
index
<div>zh</div>
@ -166,10 +154,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Other theme title</title>
</head>
<head><meta charset="UTF-8"><title>Other theme title</title></head>
<body>
<p>Other </p>
</body>
@ -182,10 +167,7 @@ public class ThemeMessageResolverIntegrationTest {
.isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Other theme title</title>
</head>
<head><meta charset="UTF-8"><title>Other theme title</title></head>
<body>
<p>other index</p>
</body>