mirror of https://github.com/halo-dev/halo
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
parent
8db4cec91e
commit
972ebed03a
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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) {
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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>
|
||||
""";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue