diff --git a/admin/settings.go b/admin/settings.go
index d08fe8b..345b1ca 100644
--- a/admin/settings.go
+++ b/admin/settings.go
@@ -51,6 +51,7 @@ var settingGroups = []settingGroupDef{
{Key: "site_tags", Label: "Site tags", Help: "Comma-separated keywords for the site's meta tags."},
{Key: "site_description", Label: "Site description", Help: "One or two sentences about the site, shown by search engines and link previews for pages without their own description."},
{Key: "site_image", Label: "Site image", Help: "Picture shown in link previews (Open Graph) for pages without their own, and as the fallback image in a post's structured data."},
+ {Key: "site_is_person", Label: "This site is about a person", Help: "Tells search engines the home page belongs to a person: the site title is their name, and the profiles set in the Social Icons plugin are theirs. Leave off for a project or an organisation."},
{Key: "site_url", Label: "Site URL", Help: "Public address of this site, used for links in feeds, emails and the sitemap."},
{Key: "robots_tag", Label: "Robots tag", Help: "Value of the robots meta tag, for example \"index, follow\"."},
}},
diff --git a/blog/blog_test.go b/blog/blog_test.go
index 85fb503..a15fdb4 100644
--- a/blog/blog_test.go
+++ b/blog/blog_test.go
@@ -1674,6 +1674,23 @@ func (p *describingPlugin) RenderPage(ctx *plugin.HookContext, pageType string)
return "page_content.html", gin.H{"has_plugin_content": true, "plugin_content": "
hi
", "title": "Hello plugin", "meta_description": "Hello says hi to ."}
}
+// socialPlugin stands in for the Social Icons plugin: two profile links,
+// in the shape a wasm plugin's template_data decodes to.
+type socialPlugin struct{ plugin.BasePlugin }
+
+func (p *socialPlugin) Name() string { return "socialicons" }
+func (p *socialPlugin) DisplayName() string { return "Social Icons" }
+func (p *socialPlugin) Version() string { return "1.0.0" }
+func (p *socialPlugin) Settings() []plugin.SettingDefinition {
+ return []plugin.SettingDefinition{{Key: "enabled", Type: "text", DefaultValue: "true", Label: "Enabled"}}
+}
+func (p *socialPlugin) TemplateData(_ *plugin.HookContext) gin.H {
+ return gin.H{"links": []any{
+ map[string]any{"Name": "GitHub", "URL": "https://github.com/me", "Icon": "fab fa-github"},
+ map[string]any{"Name": "LinkedIn", "URL": "https://www.linkedin.com/in/me/", "Icon": "fab fa-linkedin"},
+ }}
+}
+
// TestHeadMetadata: the shared _head gives every page a description,
// canonical link and Open Graph tags from the site settings and the page's
// own data, and structured data that does not name any particular site.
@@ -1697,6 +1714,7 @@ func TestHeadMetadata(t *testing.T) {
b := blog.New(db, a, "test")
reg := plugin.NewRegistry(db)
reg.Register(&describingPlugin{})
+ reg.Register(&socialPlugin{})
reg.Init()
b.PageFilter = blog.PluginPageFilter(reg)
@@ -1743,6 +1761,9 @@ func TestHeadMetadata(t *testing.T) {
if h := head(get("/")); strings.Contains(h, "og:image") || strings.Contains(h, "jason.jpg") {
t.Errorf("no site_image: no og:image and nothing site-specific:\n%s", h)
}
+ if h := head(get("/")); strings.Contains(h, `"Person"`) {
+ t.Errorf("no site_is_person: the home page must not claim to be a person's:\n%s", h)
+ }
// The post is canonical at its permalink whichever URL it was read at.
for _, u := range []string{"/posts/2026/08/15/hello", "/2026/08/15/hello"} {
@@ -1803,6 +1824,26 @@ func TestHeadMetadata(t *testing.T) {
db.Create(&blog.Setting{Key: "site_image", Value: "/img/card.png"})
check(t, "/", ``)
check(t, "/posts/2026/08/15/hello", `"https://www.example.test/img/card.png"`)
+
+ // A personal site's home page names the person and their profiles, as
+ // valid JSON; other pages do not repeat it.
+ db.Create(&blog.Setting{Key: "site_is_person", Value: "true"})
+ h := head(get("/"))
+ ld := h[strings.LastIndex(h, `")]
+ var person map[string]any
+ if err := json.Unmarshal([]byte(ld), &person); err != nil {
+ t.Fatalf("person structured data is not JSON (%v):\n%s", err, ld)
+ }
+ if person["@type"] != "Person" || person["name"] != "GoBlog" || person["url"] != "https://www.example.test/" || person["image"] != "https://www.example.test/img/card.png" {
+ t.Errorf("person = %v", person)
+ }
+ if same, _ := person["sameAs"].([]any); len(same) != 2 || same[0] != "https://github.com/me" || same[1] != "https://www.linkedin.com/in/me/" {
+ t.Errorf("sameAs = %v", person["sameAs"])
+ }
+ if h := head(get("/dir/hello")); strings.Contains(h, `"sameAs"`) {
+ t.Errorf("only the home page carries the person:\n%s", h)
+ }
}
// titlingPlugin owns "dir" and names its sub-page.
diff --git a/templates/shared/_head.html b/templates/shared/_head.html
index 3a7584f..f29c65c 100644
--- a/templates/shared/_head.html
+++ b/templates/shared/_head.html
@@ -83,6 +83,24 @@
}
}
+ {{- /* A personal site says whose it is: the site title is the person's name, and sameAs lists the profiles the Social Icons plugin holds (.plugins.socialicons.links), which is how a search engine ties them to this site. Off unless site_is_person is set. */}}
+ {{ with index .settings "site_is_person" }}{{ if eq .Value "true" }}
+
+ {{ end }}{{ end }}
{{ end }}
{{ end }}
{{ if $site }}{{ end }}
diff --git a/tools/migrate.go b/tools/migrate.go
index 1c12c24..8b7d1ed 100644
--- a/tools/migrate.go
+++ b/tools/migrate.go
@@ -334,6 +334,7 @@ func seedDefaultSettings(db *gorm.DB) {
{Key: "site_url", Type: "text", Value: "https://www.example.com"},
{Key: "site_description", Type: "textarea", Value: ""},
{Key: "site_image", Type: "file", Value: ""},
+ {Key: "site_is_person", Type: "checkbox", Value: "false"},
{Key: "comments_require_login", Type: "checkbox", Value: "true"},
{Key: "plugin_directory_url", Type: "text", Value: "https://www.goblog.live/plugins/index.json"},
{Key: "theme_directory_url", Type: "text", Value: "https://www.goblog.live/themes/index.json"},