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"},