Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions admin/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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\"."},
}},
Expand Down
41 changes: 41 additions & 0 deletions blog/blog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<p>hi</p>", "title": "Hello plugin", "meta_description": "Hello says hi to <everyone>."}
}

// 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.
Expand All @@ -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)

Expand Down Expand Up @@ -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"} {
Expand Down Expand Up @@ -1803,6 +1824,26 @@ func TestHeadMetadata(t *testing.T) {
db.Create(&blog.Setting{Key: "site_image", Value: "/img/card.png"})
check(t, "/", `<meta property="og:image" content="https://www.example.test/img/card.png">`)
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, `<script type="application/ld+json">`)+len(`<script type="application/ld+json">`):]
ld = ld[:strings.Index(ld, "</script>")]
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.
Expand Down
18 changes: 18 additions & 0 deletions templates/shared/_head.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@
}
}
</script>
{{- /* 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" }}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "{{ $.settings.site_title.Value }}",
{{ if $description }}"description": "{{ $description }}",{{ end }}
{{ if $image }}"image": "{{ $image }}",{{ end }}
"sameAs": [
{{- with $.plugins }}{{ with index . "socialicons" }}{{ range $i, $l := index . "links" }}{{ if $i }},{{ end }}
"{{ $l.URL }}"
{{- end }}{{ end }}{{ end }}
],
"url": "{{ $site }}/"
}
</script>
{{ end }}{{ end }}
{{ end }}
{{ end }}
{{ if $site }}<link rel="alternate" type="application/rss+xml" title="{{ .settings.site_title.Value }}" href="{{ $site }}/rss.xml">{{ end }}
Expand Down
1 change: 1 addition & 0 deletions tools/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
Loading