feat: theme templates support global seo settings (#2801)

#### What type of PR is this?
/kind improvement
/area core
/milestone 2.0.0-rc.2

#### What this PR does / why we need it:
适配 SEO 系统设置,此功能之前遗漏

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

Fixes #2797

#### Special notes for your reviewer:
how to test it?
1. seo 设置禁用搜索引擎,会在所有页面的 head 添加 `<meta name="robots" content="noindex" />`
2. seo 设置填写`关键词`和`描述`会在所有页面的 head 标签填充 `<meta name="xxx> content="xxx" />` 

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

```release-note
支持 SEO 全局系统设置
```
pull/2795/head^2
guqing 2022-11-30 10:31:46 +08:00 committed by GitHub
parent eefdd27c44
commit 9d60b8ae06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 175 additions and 16 deletions

View File

@ -2,6 +2,7 @@ package run.halo.app.theme.dialect;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.ITemplateContext;
@ -11,34 +12,39 @@ import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
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.router.strategy.ModelConst;
/**
* <p>The <code>head</code> html snippet injection processor for post template.</p>
* <p>The <code>head</code> html snippet injection processor for content template such as post
* and page.</p>
*
* @author guqing
* @since 2.0.0
*/
@Component
public class PostTemplateHeadProcessor implements TemplateHeadProcessor {
@AllArgsConstructor
public class ContentTemplateHeadProcessor implements TemplateHeadProcessor {
private static final String POST_NAME_VARIABLE = "name";
private final PostFinder postFinder;
public PostTemplateHeadProcessor(PostFinder postFinder) {
this.postFinder = postFinder;
}
private final SinglePageFinder singlePageFinder;
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
if (!isPostTemplate(context)) {
return Mono.empty();
Mono<String> nameMono = Mono.justOrEmpty((String) context.getVariable(POST_NAME_VARIABLE));
Mono<List<Map<String, String>>> htmlMetasMono = Mono.empty();
if (isPostTemplate(context)) {
htmlMetasMono = nameMono.flatMap(postFinder::getByName)
.map(post -> post.getSpec().getHtmlMetas());
} else if (isPageTemplate(context)) {
htmlMetasMono = nameMono.flatMap(singlePageFinder::getByName)
.map(page -> page.getSpec().getHtmlMetas());
}
return Mono.justOrEmpty((String) context.getVariable(POST_NAME_VARIABLE))
.flatMap(postFinder::getByName)
.doOnNext(postVo -> {
List<Map<String, String>> htmlMetas = postVo.getSpec().getHtmlMetas();
return htmlMetasMono
.doOnNext(htmlMetas -> {
String metaHtml = headMetaBuilder(htmlMetas);
IModelFactory modelFactory = context.getModelFactory();
model.add(modelFactory.createText(metaHtml));
@ -65,4 +71,9 @@ public class PostTemplateHeadProcessor implements TemplateHeadProcessor {
return DefaultTemplateEnum.POST.getValue()
.equals(context.getVariable(ModelConst.TEMPLATE_ID));
}
private boolean isPageTemplate(ITemplateContext context) {
return DefaultTemplateEnum.SINGLE_PAGE.getValue()
.equals(context.getVariable(ModelConst.TEMPLATE_ID));
}
}

View File

@ -0,0 +1,59 @@
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.annotation.Order;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IModelFactory;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
/**
* Inject code to the template head tag according to the global seo settings.
*
* @author guqing
* @see SystemSetting.Seo
* @since 2.0.0
*/
@Order
@Component
@AllArgsConstructor
public class GlobalSeoProcessor implements TemplateHeadProcessor {
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
return environmentFetcher.fetch(SystemSetting.Seo.GROUP, SystemSetting.Seo.class)
.map(seo -> {
boolean blockSpiders = BooleanUtils.isTrue(seo.getBlockSpiders());
IModelFactory modelFactory = context.getModelFactory();
if (blockSpiders) {
String noIndexMeta = "<meta name=\"robots\" content=\"noindex\" />\n";
model.add(modelFactory.createText(noIndexMeta));
return model;
}
String keywords = seo.getKeywords();
if (StringUtils.isNotBlank(keywords)) {
String keywordsMeta =
"<meta name=\"keywords\" content=\"" + keywords + "\" />\n";
model.add(modelFactory.createText(keywordsMeta));
}
if (StringUtils.isNotBlank(seo.getDescription())) {
String descriptionMeta =
"<meta name=\"description\" content=\"" + seo.getDescription() + "\" />\n";
model.add(modelFactory.createText(descriptionMeta));
}
return model;
})
.then();
}
}

View File

@ -33,6 +33,7 @@ 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.strategy.ModelConst;
@ -42,7 +43,7 @@ import run.halo.app.theme.router.strategy.ModelConst;
* @author guqing
* @see HaloProcessorDialect
* @see GlobalHeadInjectionProcessor
* @see PostTemplateHeadProcessor
* @see ContentTemplateHeadProcessor
* @see TemplateHeadProcessor
* @see TemplateGlobalHeadProcessor
* @see TemplateFooterElementTagProcessor
@ -56,6 +57,9 @@ class HaloProcessorDialectTest {
@Mock
private PostFinder postFinder;
@Mock
private SinglePageFinder singlePageFinder;
@Mock
private SystemConfigurableEnvironmentFetcher fetcher;
@ -72,9 +76,11 @@ class HaloProcessorDialectTest {
templateEngine.addTemplateResolver(new TestTemplateResolver());
Map<String, TemplateHeadProcessor> map = new HashMap<>();
map.put("postTemplateHeadProcessor", new PostTemplateHeadProcessor(postFinder));
map.put("postTemplateHeadProcessor",
new ContentTemplateHeadProcessor(postFinder, singlePageFinder));
map.put("templateGlobalHeadProcessor", new TemplateGlobalHeadProcessor(fetcher));
map.put("faviconHeadProcessor", new DefaultFaviconHeadProcessor(fetcher));
map.put("globalSeoProcessor", new GlobalSeoProcessor(fetcher));
lenient().when(applicationContext.getBeansOfType(eq(TemplateHeadProcessor.class)))
.thenReturn(map);
@ -85,8 +91,10 @@ class HaloProcessorDialectTest {
when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP),
eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection));
when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class)))
lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class)))
.thenReturn(fetcher);
lenient().when(fetcher.fetch(eq(SystemSetting.Seo.GROUP),
eq(SystemSetting.Seo.class))).thenReturn(Mono.empty());
when(applicationContext.getBean(eq(ExtensionComponentsFinder.class)))
.thenReturn(extensionComponentsFinder);
@ -172,6 +180,68 @@ class HaloProcessorDialectTest {
""");
}
@Test
void blockSeo() {
final Context context = getContext();
SystemSetting.Seo seo = new SystemSetting.Seo();
seo.setBlockSpiders(true);
when(fetcher.fetch(eq(SystemSetting.Seo.GROUP),
eq(SystemSetting.Seo.class))).thenReturn(Mono.just(seo));
SystemSetting.Basic basic = new SystemSetting.Basic();
basic.setFavicon("favicon.ico");
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
String result = templateEngine.process("seo", context);
assertThat(result).isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Seo Test</title>
<meta name="global-head-test" content="test" />
<link rel="icon" href="favicon.ico" />
<meta name="robots" content="noindex" />
</head>
<body>
seo setting test.
</body>
</html>
""");
}
@Test
void seoWithKeywordsAndDescription() {
final Context context = getContext();
SystemSetting.Seo seo = new SystemSetting.Seo();
seo.setKeywords("K1, K2, K3");
seo.setDescription("This is a description.");
when(fetcher.fetch(eq(SystemSetting.Seo.GROUP),
eq(SystemSetting.Seo.class))).thenReturn(Mono.just(seo));
SystemSetting.Basic basic = new SystemSetting.Basic();
basic.setFavicon("favicon.ico");
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
String result = templateEngine.process("seo", context);
assertThat(result).isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<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="description" content="This is a description." />
</head>
<body>
seo setting test.
</body>
</html>
""");
}
private Context getContext() {
Context context = new Context();
context.setVariable(
@ -192,6 +262,10 @@ class HaloProcessorDialectTest {
if (template.equals(DefaultTemplateEnum.POST.getValue())) {
return new StringTemplateResource(postTemplate());
}
if (template.equals("seo")) {
return new StringTemplateResource(seoTemplate());
}
return null;
}
@ -227,5 +301,20 @@ class HaloProcessorDialectTest {
</html>
""";
}
private String seoTemplate() {
return """
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>Seo Test</title>
</head>
<body>
seo setting test.
</body>
</html>
""";
}
}
}