mirror of https://github.com/halo-dev/halo
Add generator meta into head (#4821)
#### What type of PR is this? /kind feature /area core /milestone 2.11.x #### What this PR does / why we need it: Please see https://html.spec.whatwg.org/multipage/semantics.html#meta-generator for more. This PR add the generator meta into head, so that we can know what sites are using Halo. ```bash http localhost:8090/ | grep generator <meta name="generator" content="Halo 2.11.0-SNAPSHOT"/> ``` If someone want to disable the generator meta, they can configure the property `halo.theme.generator-meta-disabled` to `true`. #### Does this PR introduce a user-facing change? ```release-note 添加 Generator 元数据标识 ```pull/4844/head
parent
caa4d44907
commit
4ea20142f5
|
@ -9,6 +9,11 @@ public class ThemeProperties {
|
||||||
@Valid
|
@Valid
|
||||||
private final Initializer initializer = new Initializer();
|
private final Initializer initializer = new Initializer();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the generator meta needs to be disabled.
|
||||||
|
*/
|
||||||
|
private boolean generatorMetaDisabled;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class Initializer {
|
public static class Initializer {
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,10 @@ import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
@ -21,8 +24,10 @@ import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.infra.ThemeRootGetter;
|
import run.halo.app.infra.ThemeRootGetter;
|
||||||
import run.halo.app.infra.utils.FileUtils;
|
import run.halo.app.infra.utils.FileUtils;
|
||||||
|
import run.halo.app.theme.dialect.GeneratorMetaProcessor;
|
||||||
import run.halo.app.theme.dialect.HaloSpringSecurityDialect;
|
import run.halo.app.theme.dialect.HaloSpringSecurityDialect;
|
||||||
import run.halo.app.theme.dialect.LinkExpressionObjectDialect;
|
import run.halo.app.theme.dialect.LinkExpressionObjectDialect;
|
||||||
|
import run.halo.app.theme.dialect.TemplateHeadProcessor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author guqing
|
* @author guqing
|
||||||
|
@ -86,4 +91,12 @@ public class ThemeConfiguration {
|
||||||
ServerSecurityContextRepository securityContextRepository) {
|
ServerSecurityContextRepository securityContextRepository) {
|
||||||
return new HaloSpringSecurityDialect(securityContextRepository);
|
return new HaloSpringSecurityDialect(securityContextRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "halo.theme.generator-meta-disabled",
|
||||||
|
havingValue = "false",
|
||||||
|
matchIfMissing = true)
|
||||||
|
TemplateHeadProcessor generatorMetaProcessor(ObjectProvider<BuildProperties> buildProperties) {
|
||||||
|
return new GeneratorMetaProcessor(buildProperties);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
package run.halo.app.theme.dialect;
|
||||||
|
|
||||||
|
import static org.thymeleaf.model.AttributeValueQuotes.DOUBLE;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.thymeleaf.context.ITemplateContext;
|
||||||
|
import org.thymeleaf.model.IModel;
|
||||||
|
import org.thymeleaf.processor.element.IElementModelStructureHandler;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor for generating generator meta.
|
||||||
|
* Set the order to 0 for removing the meta in later TemplateHeadProcessor.
|
||||||
|
*
|
||||||
|
* @author johnniang
|
||||||
|
*/
|
||||||
|
@Order(0)
|
||||||
|
public class GeneratorMetaProcessor implements TemplateHeadProcessor {
|
||||||
|
|
||||||
|
private final String generatorValue;
|
||||||
|
|
||||||
|
public GeneratorMetaProcessor(ObjectProvider<BuildProperties> buildProperties) {
|
||||||
|
this.generatorValue = "Halo " + buildProperties.stream().findFirst()
|
||||||
|
.map(BuildProperties::getVersion)
|
||||||
|
.orElse("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> process(ITemplateContext context, IModel model,
|
||||||
|
IElementModelStructureHandler structureHandler) {
|
||||||
|
return Mono.fromRunnable(() -> {
|
||||||
|
var modelFactory = context.getModelFactory();
|
||||||
|
var generatorMeta = modelFactory.createStandaloneElementTag("meta",
|
||||||
|
Map.of("name", "generator", "content", generatorValue),
|
||||||
|
DOUBLE, false, true);
|
||||||
|
model.add(generatorMeta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
package run.halo.app.theme.dialect;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import org.springframework.util.ResourceUtils;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.infra.InitializationStateGetter;
|
||||||
|
import run.halo.app.theme.ThemeContext;
|
||||||
|
import run.halo.app.theme.ThemeResolver;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureWebTestClient
|
||||||
|
class GeneratorMetaProcessorTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
WebTestClient webClient;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
InitializationStateGetter initializationStateGetter;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
ThemeResolver themeResolver;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws FileNotFoundException, URISyntaxException {
|
||||||
|
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
|
||||||
|
var themeContext = ThemeContext.builder()
|
||||||
|
.name("default")
|
||||||
|
.path(Path.of(ResourceUtils.getURL("classpath:themes/default").toURI()))
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
when(themeResolver.getTheme(any(ServerWebExchange.class)))
|
||||||
|
.thenReturn(Mono.just(themeContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requestIndexPage() {
|
||||||
|
webClient.get().uri("/")
|
||||||
|
.exchange()
|
||||||
|
.expectStatus().isOk()
|
||||||
|
.expectBody()
|
||||||
|
.consumeWith(System.out::println)
|
||||||
|
.xpath("/html/head/meta[@name=\"generator\"][starts-with(@content, \"Halo \")]")
|
||||||
|
.exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -68,21 +68,9 @@ public class ThemeMessageResolverIntegrationTest {
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectStatus()
|
.expectStatus()
|
||||||
.isOk()
|
.isOk()
|
||||||
.expectBody(String.class)
|
.expectBody()
|
||||||
.isEqualTo("""
|
.xpath("/html/body/div[1]").isEqualTo("zh")
|
||||||
<!DOCTYPE html>
|
.xpath("/html/body/div[2]").isEqualTo("欢迎来到首页");
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Title</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
index
|
|
||||||
<div>zh</div>
|
|
||||||
<div>欢迎来到首页</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -92,21 +80,9 @@ public class ThemeMessageResolverIntegrationTest {
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectStatus()
|
.expectStatus()
|
||||||
.isOk()
|
.isOk()
|
||||||
.expectBody(String.class)
|
.expectBody()
|
||||||
.isEqualTo("""
|
.xpath("/html/body/div[1]").isEqualTo("en")
|
||||||
<!DOCTYPE html>
|
.xpath("/html/body/div[2]").isEqualTo("Welcome to the index");
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Title</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
index
|
|
||||||
<div>en</div>
|
|
||||||
<div>Welcome to the index</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -116,21 +92,9 @@ public class ThemeMessageResolverIntegrationTest {
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectStatus()
|
.expectStatus()
|
||||||
.isOk()
|
.isOk()
|
||||||
.expectBody(String.class)
|
.expectBody()
|
||||||
.isEqualTo("""
|
.xpath("/html/body/div[1]").isEqualTo("foo")
|
||||||
<!DOCTYPE html>
|
.xpath("/html/body/div[2]").isEqualTo("欢迎来到首页");
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Title</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
index
|
|
||||||
<div>foo</div>
|
|
||||||
<div>欢迎来到首页</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -140,21 +104,11 @@ public class ThemeMessageResolverIntegrationTest {
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectStatus()
|
.expectStatus()
|
||||||
.isOk()
|
.isOk()
|
||||||
.expectBody(String.class)
|
.expectBody()
|
||||||
.isEqualTo("""
|
.xpath("/html/head/title").isEqualTo("Title")
|
||||||
<!DOCTYPE html>
|
.xpath("/html/body/div[1]").isEqualTo("zh")
|
||||||
<html lang="en">
|
.xpath("/html/body/div[2]").isEqualTo("欢迎来到首页")
|
||||||
<head>
|
;
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Title</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
index
|
|
||||||
<div>zh</div>
|
|
||||||
<div>欢迎来到首页</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""");
|
|
||||||
|
|
||||||
// For other theme
|
// For other theme
|
||||||
when(themeResolver.getTheme(any(ServerWebExchange.class)))
|
when(themeResolver.getTheme(any(ServerWebExchange.class)))
|
||||||
|
@ -162,35 +116,16 @@ public class ThemeMessageResolverIntegrationTest {
|
||||||
webTestClient.get()
|
webTestClient.get()
|
||||||
.uri("/index?language=zh")
|
.uri("/index?language=zh")
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectBody(String.class)
|
.expectBody()
|
||||||
.isEqualTo("""
|
.xpath("/html/head/title").isEqualTo("Other theme title")
|
||||||
<!DOCTYPE html>
|
.xpath("/html/body/p").isEqualTo("Other 首页");
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Other theme title</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>Other 首页</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""");
|
|
||||||
webTestClient.get()
|
webTestClient.get()
|
||||||
.uri("/index?language=en")
|
.uri("/index?language=en")
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectBody(String.class)
|
.expectBody()
|
||||||
.isEqualTo("""
|
.xpath("/html/head/title").isEqualTo("Other theme title")
|
||||||
<!DOCTYPE html>
|
.xpath("/html/body/p").isEqualTo("other index");
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Other theme title</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>other index</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ThemeContext createDefaultContext() throws URISyntaxException {
|
ThemeContext createDefaultContext() throws URISyntaxException {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8"/>
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8"/>
|
||||||
<title>Other theme title</title>
|
<title>Other theme title</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
Loading…
Reference in New Issue