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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

A blog engine where you write first and categorize never.

Type two sentences without a title and it becomes a thought. Paste a URL with commentary and it becomes a link. Write something long with a title and it becomes an article. Ask a question and it becomes an AMA. Four content types, inferred automatically from what you write. No database. No build step.
Type two sentences without a title and it becomes a thought. Paste a URL with commentary and it becomes a link. Write something long with a title and it becomes an article. Ask a question and it becomes an AMA. Plus a fifth type — page — for evergreen content (explicit `type: page` in frontmatter, served at `/p/<slug>`, listed at `/p`). Four inferred, one explicit. No database. No build step.

## Quick Start

Expand Down
14 changes: 6 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ The slug determines the URL: `/p/run-your-own` in this example. Pages support th

- **URL:** `/p/<slug>` is canonical. Legacy `/writing/<slug>` requests for `type: page` articles 301-redirect to `/p/<slug>`.
- **Feed exclusion:** pages do not appear in `/writing`, RSS (`/feed.xml`), JSON Feed (`/feed.json`), `/tags/<tag>`, or `/categories/<cat>`.
- **Sitemap:** pages are not currently emitted in the sitemap article section. Search engines discover pages via canonical links from articles or via direct URL.
- **Sitemap:** pages are emitted in `sitemap.xml` with their canonical `/p/<slug>` URLs, alongside a static `/p` index entry. `/about` is also included via the same predicate-aware logic (v3.14.0+).
- **Search:** pages remain indexed; readers can find them via `/search?q=...` and follow the result to `/p/<slug>`.
- **Date and "Updated" line:** hidden in the rendered page. Pages are evergreen, not dated.
- **Tags:** rendered on the page itself but do not surface the page in tag indexes.
Expand All @@ -160,10 +160,8 @@ Change `type: article` → `type: page` in the frontmatter and save. The article

### Future enhancements

- Auto-populated nav slot listing pages (v3.14.0+)
- `/p` index page (v3.14.0+; currently returns 404)
- `nav_priority` ordering frontmatter (v3.14.0+)
- Compose form "new page" affordance (v3.14.0+; for now, author pages by dropping markdown into `articles/` or editing an existing page via `/compose/edit/<slug>`)
- `nav_priority` ordering frontmatter (v3.14.0+ deferred; for now pages are listed alphabetically on `/p`)
- Compose form "new page" affordance (v3.14.0+ deferred; for now, author pages by dropping markdown into `articles/` or editing an existing page via `/compose/edit/<slug>`)

## Admin

Expand Down Expand Up @@ -234,10 +232,10 @@ Operator-controllable copy on the AMA (Ask Me Anything) submission overlay. All

| Variable | Default | Description |
|----------|---------|-------------|
| `AMA_PAGE_HEADING` | `Ask me anything` | Heading shown at the top of the AMA sheet. |
| `AMA_PAGE_INTRO` | `Curious about something? Submit a question and I'll answer it publicly.` | Intro paragraph rendered between the heading and the form. |
| `AMA_PAGE_HEADING` | `Ask me anything` | Heading shown at the top of the AMA sheet AND in the `/about` reach section (v3.14.0+). Setting this empty in `.env` falls back to the default — hiding the AMA half requires overriding `about.html` via `TEMPLATES_PATH`. |
| `AMA_PAGE_INTRO` | `Curious about something? Submit a question and I'll answer it publicly.` | Intro paragraph rendered between the heading and the form on both the AMA sheet and the `/about` reach section (v3.14.0+). |
| `AMA_FORM_PLACEHOLDER` | `What would you like to know?` | Placeholder for the question textarea. |
| `AMA_SUBMIT_LABEL` | `Submit Question` | Label on the submit button. |
| `AMA_SUBMIT_LABEL` | `Submit Question` | Label on the submit button on both the AMA sheet and the `/about` reach section (v3.14.0+). |
| `AMA_THANKYOU_COPY` | `Question submitted! It will appear once answered.` | Toast shown after a successful submission. |

## Logging
Expand Down
6 changes: 3 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ The voice is **conversational and direct**. It sounds like a person, not a produ

---

## The Three Streams
## Content Types

MarkGo's content model has three types, inferred automatically from what you write. You never pick a type from a dropdown — the system figures it out from the shape of your content. This is MarkGo's most distinctive design decision.
MarkGo's content model has five types: four inferred automatically from what you write, plus pages for explicit evergreen content. You never pick a type from a dropdown — the system figures it out from the shape of your content. This is MarkGo's most distinctive design decision.

**Thoughts** — No title, under 100 words. A fleeting idea, a reaction, a note-to-self that's worth sharing. Displayed as a simple text card with a left accent stripe in `--color-primary`. The full thought is visible in the feed — no teaser, no truncation. Shows relative time ("2 hours ago") and tags.

Expand All @@ -125,7 +125,7 @@ The inference rules are intentionally simple (see `internal/services/article/inf

For pages, the type field must be explicit (`type: page`) — pages have no inferable signal and shouldn't drift into existence by accident. The other four types (article/thought/link/ama) infer freely.

The first four types live in the same feed, filterable by type via server-rendered `<a>` tag pills with query parameters (`/?type=thought`). Pages live outside the feed entirely; readers reach them by direct link or search.
The first four types live in the same feed, filterable by type via server-rendered `<a>` tag pills with query parameters (`/?type=thought`). Pages live outside the feed entirely; readers reach them via the `/p` index (linked from the footer), direct link, sitemap, or search.

---

Expand Down
2 changes: 2 additions & 0 deletions internal/commands/serve/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ func setupRoutes(router *gin.Engine, h *handlers.Router, sessionStore *middlewar
registerGET(router, "/", h.Feed.Home)
registerGET(router, "/writing", h.Post.Articles)
registerGET(router, "/writing/:slug", h.Post.Article)
registerGET(router, "/p", h.Post.Pages)
registerGET(router, "/p/:slug", h.Post.Page)
registerGET(router, "/tags", h.Taxonomy.Tags)
registerGET(router, "/tags/:tag", h.Taxonomy.ArticlesByTag)
Expand Down Expand Up @@ -554,6 +555,7 @@ func setupTemplates(router *gin.Engine, templateService *services.TemplateServic
"admin_ama.html",
"category.html",
"tag.html",
"pages.html",
}

for _, tmplName := range requiredTemplates {
Expand Down
27 changes: 26 additions & 1 deletion internal/handlers/about_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,21 @@ func NewAboutHandler(
}
}

// ShowAbout handles the GET /about route.
// ShowAbout handles the GET /about route. Template data keys consumed by
// about.html:
//
// Identity: about_avatar, about_tagline, about_location
// Bio: bio_html (rendered HTML; absent if neither about.md nor
// ABOUT_BIO set)
// Social: social_links []socialLink, has_social
// Contact: has_contact (BLOG_AUTHOR_EMAIL set),
// has_contact_form (full SMTP-backed form available)
// Reach: about_ama_heading, about_ama_intro, about_ama_label
// (v3.14.0+, closes #75 — AMA copy from the existing v3.11.0
// AMA_PAGE_* env vars so /ama and /about speak in the same
// operator voice; AMA half always renders, see below)
//
// Plus standard base-template keys via buildBaseTemplateData.
func (h *AboutHandler) ShowAbout(c *gin.Context) {
cfg := h.config
data := h.buildBaseTemplateData("About - " + cfg.Blog.Title)
Expand Down Expand Up @@ -78,6 +92,17 @@ func (h *AboutHandler) ShowAbout(c *gin.Context) {
data["has_contact"] = hasEmail
data["has_contact_form"] = hasContactForm

// Reach section (v3.14.0+): operator-voiced AMA promo reusing the
// v3.11.0 AMA_PAGE_* env vars. The AMA card in about.html renders
// unconditionally regardless of has_contact_form, matching pre-v3.14.0
// behavior where /about always showed an AMA section. The mailto card
// inside about-reach is gated on has_contact && !has_contact_form (full
// SMTP form supplants the mailto fallback). Hiding the AMA card
// requires overriding about.html via TEMPLATES_PATH.
data["about_ama_heading"] = cfg.AMA.PageHeading
data["about_ama_intro"] = cfg.AMA.PageIntro
data["about_ama_label"] = cfg.AMA.SubmitLabel

h.enhanceTemplateDataWithSEO(data, "/about")
h.renderHTML(c, http.StatusOK, "base.html", data)
}
Expand Down
209 changes: 129 additions & 80 deletions internal/handlers/about_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,120 +9,169 @@ import (

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

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

func createTestAboutHandler(cfg *config.Config) *AboutHandler {
func createTestAboutHandler(cfg *config.Config) (*AboutHandler, *MockTemplateService) {
if cfg == nil {
cfg = createTestConfig()
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))

base := NewBaseHandler(cfg, logger, &MockTemplateService{}, &BuildInfo{Version: "test"}, &MockSEOService{})
return NewAboutHandler(base, &MockArticleService{}, &MockMarkdownRenderer{})
mockTpl := &MockTemplateService{}
base := NewBaseHandler(cfg, logger, mockTpl, &BuildInfo{Version: "test"}, &MockSEOService{})
return NewAboutHandler(base, &MockArticleService{}, &MockMarkdownRenderer{}), mockTpl
}

func TestAboutHandler(t *testing.T) {
t.Run("minimal config shows author name", func(t *testing.T) {
cfg := &config.Config{
// TestAboutHandler_TemplateData verifies the ShowAbout data contract
// (v3.14.0+ closes #75). The AMA copy keys must reach the template
// verbatim from the v3.11.0 AMA_PAGE_* env vars (operator voice), and
// has_contact / has_contact_form together gate the mailto card inside
// about-reach. The AMA card in the template always renders regardless
// of has_contact_form — matches pre-v3.14.0 behavior where /about
// always showed an AMA section.
func TestAboutHandler_TemplateData(t *testing.T) {
baseCfg := func() *config.Config {
return &config.Config{
Environment: "test",
BaseURL: "http://localhost:3000",
Blog: config.BlogConfig{
Title: "Test Blog",
Author: "Test Author",
Title: "Test Blog",
Author: "Test Author",
AuthorEmail: "author@example.com",
},
AMA: config.AMAConfig{
PageHeading: "Ask me anything",
PageIntro: "Curious about something?",
SubmitLabel: "Submit Question",
},
}
}

handler := createTestAboutHandler(cfg)

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

req := httptest.NewRequest("GET", "/about", http.NoBody)
req.Header.Set("Accept", "application/json")
w := httptest.NewRecorder()

router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
})

t.Run("full config sets all template data", func(t *testing.T) {
cfg := &config.Config{
Environment: "test",
BaseURL: "http://localhost:3000",
Blog: config.BlogConfig{
Title: "Test Blog",
Author: "Jane Doe",
AuthorEmail: "jane@example.com",
tests := []struct {
name string
mutate func(*config.Config)
wantHasContact bool
wantHasContactForm bool
wantHeading string
wantIntro string
wantLabel string
}{
{
name: "defaults: AMA + mailto",
mutate: func(*config.Config) {},
wantHasContact: true,
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
},
{
name: "no email — AMA still renders solo",
mutate: func(c *config.Config) {
c.Blog.AuthorEmail = ""
},
About: config.AboutConfig{
Avatar: "img/avatar.jpg",
Tagline: "Building things",
Location: "San Francisco, CA",
GitHub: "janedoe",
Twitter: "@janedoe",
LinkedIn: "https://linkedin.com/in/janedoe",
Website: "janedoe.com",
wantHasContact: false,
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
},
{
name: "custom AMA copy reaches template (operator voice)",
mutate: func(c *config.Config) {
c.AMA.PageHeading = "Hit me up"
c.AMA.PageIntro = "Got something on your mind?"
c.AMA.SubmitLabel = "Send it"
},
Email: config.EmailConfig{
Host: "smtp.example.com",
Username: "user",
wantHasContact: true,
wantHeading: "Hit me up",
wantIntro: "Got something on your mind?",
wantLabel: "Send it",
},
{
// Regression guard for the v3.14.0 PR #76 finding: AMA card
// must keep rendering even when SMTP is fully configured.
// has_contact_form=true gates the mailto card but the
// template (about.html) renders AMA unconditionally.
name: "SMTP configured — AMA still rendered, mailto suppressed",
mutate: func(c *config.Config) {
c.Email.Host = "smtp.example.com"
c.Email.Username = "user"
},
}

handler := createTestAboutHandler(cfg)

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

req := httptest.NewRequest("GET", "/about", http.NoBody)
req.Header.Set("Accept", "application/json")
w := httptest.NewRecorder()

router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
})
wantHasContact: true,
wantHasContactForm: true,
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
},
}

t.Run("contact section hidden without email", func(t *testing.T) {
cfg := &config.Config{
Environment: "test",
BaseURL: "http://localhost:3000",
Blog: config.BlogConfig{
Title: "Test Blog",
Author: "Test Author",
AuthorEmail: "", // no email
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := baseCfg()
tt.mutate(cfg)
handler, mockTpl := createTestAboutHandler(cfg)

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

router := gin.New()
router.GET("/about", handler.ShowAbout)
req := httptest.NewRequest(http.MethodGet, "/about", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

req := httptest.NewRequest("GET", "/about", http.NoBody)
req.Header.Set("Accept", "application/json")
w := httptest.NewRecorder()
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, mockTpl.LastData, "ShowAbout must invoke the template renderer")

router.ServeHTTP(w, req)
assert.Equal(t, tt.wantHasContact, mockTpl.LastData["has_contact"], "has_contact (BLOG_AUTHOR_EMAIL set) feeds the mailto card visibility")
assert.Equal(t, tt.wantHasContactForm, mockTpl.LastData["has_contact_form"], "has_contact_form (SMTP configured) gates the full contact form section")
assert.Equal(t, tt.wantHeading, mockTpl.LastData["about_ama_heading"], "operator voice — AMA heading reaches the template verbatim")
assert.Equal(t, tt.wantIntro, mockTpl.LastData["about_ama_intro"])
assert.Equal(t, tt.wantLabel, mockTpl.LastData["about_ama_label"])
})
}
}

assert.Equal(t, http.StatusOK, w.Code)
})
// 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) {
cfg := &config.Config{
Environment: "test",
BaseURL: "http://localhost:3000",
Blog: config.BlogConfig{Title: "Test Blog", Author: "Jane Doe"},
About: config.AboutConfig{
Avatar: "img/avatar.jpg",
Tagline: "Building things",
Location: "San Francisco, CA",
GitHub: "janedoe",
},
}
handler, mockTpl := createTestAboutHandler(cfg)

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)
require.NotNil(t, mockTpl.LastData)
assert.Equal(t, "img/avatar.jpg", mockTpl.LastData["about_avatar"])
assert.Equal(t, "Building things", mockTpl.LastData["about_tagline"])
assert.Equal(t, "San Francisco, CA", mockTpl.LastData["about_location"])
assert.Equal(t, true, mockTpl.LastData["has_social"])
}

func TestBuildSocialLinks(t *testing.T) {
t.Run("no social links configured", func(t *testing.T) {
handler := createTestAboutHandler(nil)
handler, _ := createTestAboutHandler(nil)
links := handler.buildSocialLinks()
assert.Empty(t, links)
})

t.Run("normalizes github username to full URL", func(t *testing.T) {
cfg := createTestConfig()
cfg.About.GitHub = "testuser"
handler := createTestAboutHandler(cfg)
handler, _ := createTestAboutHandler(cfg)
links := handler.buildSocialLinks()

assert.Len(t, links, 1)
Expand All @@ -133,7 +182,7 @@ func TestBuildSocialLinks(t *testing.T) {
t.Run("preserves full URLs", func(t *testing.T) {
cfg := createTestConfig()
cfg.About.GitHub = "https://github.com/testuser"
handler := createTestAboutHandler(cfg)
handler, _ := createTestAboutHandler(cfg)
links := handler.buildSocialLinks()

assert.Len(t, links, 1)
Expand All @@ -143,7 +192,7 @@ func TestBuildSocialLinks(t *testing.T) {
t.Run("normalizes twitter handle", func(t *testing.T) {
cfg := createTestConfig()
cfg.About.Twitter = "@janedoe"
handler := createTestAboutHandler(cfg)
handler, _ := createTestAboutHandler(cfg)
links := handler.buildSocialLinks()

assert.Len(t, links, 1)
Expand All @@ -157,7 +206,7 @@ func TestBuildSocialLinks(t *testing.T) {
cfg.About.LinkedIn = "https://linkedin.com/in/user"
cfg.About.Mastodon = "https://mastodon.social/@user"
cfg.About.Website = "example.com"
handler := createTestAboutHandler(cfg)
handler, _ := createTestAboutHandler(cfg)
links := handler.buildSocialLinks()

assert.Len(t, links, 5)
Expand Down
Loading