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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ BLOG_TITLE=Your Blog Title
BLOG_TAGLINE=Your short tagline
BLOG_DESCRIPTION=Your blog description goes here
BLOG_AUTHOR=Your Name
BLOG_AUTHOR_EMAIL=your.email@example.com
BLOG_AUTHOR_EMAIL=

# Blog language (ISO 639-1 code, e.g., 'en', 'en-US')
BLOG_LANGUAGE=en
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.15.1] - 2026-05-18

### Fixed

- `BLOG_AUTHOR_EMAIL` default dropped (was `your.email@example.com`). Empty
value now hides the email card on `/about` and the `email` field in
`/about` + article JSON-LD. Operators with the old placeholder in their
`.env` should clear it. (#80)

## [3.15.0] - 2026-05-18

Theme: **page authoring + reach copy.** Wave-4 of log.1mb.dev feedback
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/init/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func isAlreadyInitialized(dir string) bool {
func getQuickDefaults() BlogConfig {
currentUser, err := user.Current()
username := "Blog Author"
email := "author@example.com"
email := ""

if err == nil && currentUser != nil {
username = currentUser.Username
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ func Load() (*Config, error) {
Tagline: getEnv("BLOG_TAGLINE", ""),
Description: getEnv("BLOG_DESCRIPTION", "Your blog description goes here"),
Author: getEnv("BLOG_AUTHOR", "Your Name"),
AuthorEmail: getEnv("BLOG_AUTHOR_EMAIL", "your.email@example.com"),
AuthorEmail: getEnv("BLOG_AUTHOR_EMAIL", ""),
Language: getEnv("BLOG_LANGUAGE", "en"),
Theme: getEnv("BLOG_THEME", "default"),
Style: getEnv("BLOG_STYLE", "minimal"),
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func TestLoad(t *testing.T) {
assert.Equal(t, "Your Blog Title", cfg.Blog.Title)
assert.Equal(t, "Your blog description goes here", cfg.Blog.Description)
assert.Equal(t, "Your Name", cfg.Blog.Author)
assert.Equal(t, "your.email@example.com", cfg.Blog.AuthorEmail)
assert.Equal(t, "", cfg.Blog.AuthorEmail)
assert.Equal(t, "en", cfg.Blog.Language)
assert.Equal(t, "default", cfg.Blog.Theme)
assert.Equal(t, 10, cfg.Blog.PostsPerPage)
Expand Down
65 changes: 65 additions & 0 deletions internal/handlers/about_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/1mb-dev/markgo/internal/config"
"github.com/1mb-dev/markgo/internal/services"
)

func createTestAboutHandler(cfg *config.Config) (*AboutHandler, *MockTemplateService) {
Expand Down Expand Up @@ -176,6 +178,69 @@ func TestAboutHandler_TemplateData(t *testing.T) {
}
}

// TestAboutHandler_JSONLDEmail locks the contract: about.html JSON-LD emits
// the "email" field only when BLOG_AUTHOR_EMAIL is configured. With an empty
// value, no "email": substring may appear inside the JSON-LD block.
// Sibling of TestArticleHandler_JSONLDEmail — locks the same contract on
// the second emission site surfaced by #80.
func TestAboutHandler_JSONLDEmail(t *testing.T) {
tests := []struct {
name string
authorEmail string
denySubstring string
wantSubstring string
}{
{
name: "empty email — JSON-LD omits email field",
authorEmail: "",
denySubstring: `"email":`,
},
{
name: "configured email — JSON-LD includes email field",
authorEmail: "author@example.com",
wantSubstring: `"email": "author@example.com"`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{
Environment: "test",
BaseURL: "http://localhost:3000",
Blog: config.BlogConfig{Title: "Test Blog", Description: "Test", Author: "Test Author", AuthorEmail: tc.authorEmail},
}
tplSvc, err := services.NewTemplateService("/nonexistent", cfg)
require.NoError(t, err, "real TemplateService falls back to embedded templates")

logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
base := NewBaseHandler(cfg, logger, tplSvc, &BuildInfo{Version: "test"}, &MockSEOService{})
handler := NewAboutHandler(base, &MockArticleService{}, &MockMarkdownRenderer{})

router := gin.New()
router.GET("/about", handler.ShowAbout)

w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/about", http.NoBody))
require.Equal(t, http.StatusOK, w.Code)

body := w.Body.String()
jsonLDStart := strings.Index(body, `<script type="application/ld+json">`)
require.GreaterOrEqual(t, jsonLDStart, 0, "JSON-LD script tag must be present in about body")
jsonLDEnd := strings.Index(body[jsonLDStart:], `</script>`)
require.Greater(t, jsonLDEnd, 0, "JSON-LD script tag must be closed")
jsonLD := body[jsonLDStart : jsonLDStart+jsonLDEnd]

if tc.denySubstring != "" {
assert.NotContains(t, jsonLD, tc.denySubstring,
"JSON-LD must not emit email field when AuthorEmail is empty")
}
if tc.wantSubstring != "" {
assert.Contains(t, jsonLD, tc.wantSubstring,
"JSON-LD must emit configured email value")
}
})
}
}

// TestAboutHandler_Identity verifies the identity/social/bio composition
// path still produces expected data after the v3.14.0 reach addition.
func TestAboutHandler_Identity(t *testing.T) {
Expand Down
64 changes: 64 additions & 0 deletions internal/handlers/article_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,70 @@ func createTestBase() (*BaseHandler, *TestArticleService) {
return base, svc
}

// TestArticleHandler_JSONLDEmail locks the contract: article.html JSON-LD
// emits the "email" field only when BLOG_AUTHOR_EMAIL is configured. With
// an empty value, no "email": substring may appear inside the JSON-LD block
// (would be invalid Schema.org). Surfaced by #80.
func TestArticleHandler_JSONLDEmail(t *testing.T) {
tests := []struct {
name string
authorEmail string
denySubstring string
wantSubstring string
}{
{
name: "empty email — JSON-LD omits email field",
authorEmail: "",
denySubstring: `"email":`,
},
{
name: "configured email — JSON-LD includes email field",
authorEmail: "author@example.com",
wantSubstring: `"email": "author@example.com"`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{
Environment: "test",
BaseURL: "http://localhost:3000",
Blog: config.BlogConfig{Title: "Test Blog", Description: "Test", Author: "Test Author", AuthorEmail: tc.authorEmail},
}
tplSvc, err := services.NewTemplateService("/nonexistent", cfg)
require.NoError(t, err, "real TemplateService falls back to embedded templates")

logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
base := NewBaseHandler(cfg, logger, tplSvc, &BuildInfo{Version: "test"}, &MockSEOService{})
svc := &TestArticleService{articles: []*models.Article{
{Slug: "test-slug", Title: "Test Article", Date: time.Now(), Content: "body", ProcessedContent: "<p>body</p>"},
}}

router := gin.New()
router.GET("/writing/:slug", NewPostHandler(base, svc).Article)

w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/writing/test-slug", http.NoBody))
require.Equal(t, http.StatusOK, w.Code)

body := w.Body.String()
jsonLDStart := strings.Index(body, `<script type="application/ld+json">`)
require.GreaterOrEqual(t, jsonLDStart, 0, "JSON-LD script tag must be present in article body")
jsonLDEnd := strings.Index(body[jsonLDStart:], `</script>`)
require.Greater(t, jsonLDEnd, 0, "JSON-LD script tag must be closed")
jsonLD := body[jsonLDStart : jsonLDStart+jsonLDEnd]

if tc.denySubstring != "" {
assert.NotContains(t, jsonLD, tc.denySubstring,
"JSON-LD must not emit email field when AuthorEmail is empty (would be invalid Schema.org)")
}
if tc.wantSubstring != "" {
assert.Contains(t, jsonLD, tc.wantSubstring,
"JSON-LD must emit configured email value")
}
})
}
}

func TestArticleBySlug(t *testing.T) {
tests := []struct {
name string
Expand Down
4 changes: 2 additions & 2 deletions web/templates/article.html
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ <h1 class="article-title">{{ .article.Title }}</h1>
{{ end }}
"author": {
"@type": "Person",
"name": "{{ if .article.Author }}{{ .article.Author }}{{ else }}{{ .config.Blog.Author }}{{ end }}",
"email": "{{ .config.Blog.AuthorEmail }}",
"name": "{{ if .article.Author }}{{ .article.Author }}{{ else }}{{ .config.Blog.Author }}{{ end }}"{{ if .config.Blog.AuthorEmail }},
"email": "{{ .config.Blog.AuthorEmail }}"{{ end }},
"url": "{{ .config.BaseURL }}"
},
"publisher": {
Expand Down