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 internal/commands/serve/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ func setupRoutes(router *gin.Engine, h *handlers.Router, sessionStore *middlewar
middleware.CSRF(secureCookie),
)
registerGET(composeGroup, "", h.Compose.ShowCompose)
registerGET(composeGroup, "/new-page", h.Compose.ShowComposeNewPage)
composeGroup.POST("", h.Compose.HandleSubmit)
registerGET(composeGroup, "/edit/:slug", h.Compose.ShowEdit)
composeGroup.POST("/edit/:slug", h.Compose.HandleEdit)
Expand Down
42 changes: 24 additions & 18 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,18 @@ type LoggingConfig struct {

// AboutConfig holds about page configuration options.
type AboutConfig struct {
Avatar string `json:"avatar"` // path relative to static dir
Tagline string `json:"tagline"` // one-liner under name
Bio string `json:"bio"` // markdown text (alt to about.md)
Location string `json:"location"` // e.g. "San Francisco, CA"
GitHub string `json:"github"` // username or full URL
Twitter string `json:"twitter"` // handle or full URL
LinkedIn string `json:"linkedin"` // full URL
Mastodon string `json:"mastodon"` // full URL
Website string `json:"website"` // full URL
Avatar string `json:"avatar"` // path relative to static dir
Tagline string `json:"tagline"` // one-liner under name
Bio string `json:"bio"` // markdown text (alt to about.md)
Location string `json:"location"` // e.g. "San Francisco, CA"
GitHub string `json:"github"` // username or full URL
Twitter string `json:"twitter"` // handle or full URL
LinkedIn string `json:"linkedin"` // full URL
Mastodon string `json:"mastodon"` // full URL
Website string `json:"website"` // full URL
ReachHeading string `json:"reach_heading"` // h2 above the reach cards
EmailHeading string `json:"email_heading"` // email-card h3
EmailIntro string `json:"email_intro"` // email-card descriptive text
}

// UploadConfig holds file upload configuration options.
Expand Down Expand Up @@ -281,15 +284,18 @@ func Load() (*Config, error) {
},

About: AboutConfig{
Avatar: getEnv("ABOUT_AVATAR", ""),
Tagline: getEnv("ABOUT_TAGLINE", ""),
Bio: getEnv("ABOUT_BIO", ""),
Location: getEnv("ABOUT_LOCATION", ""),
GitHub: getEnv("ABOUT_GITHUB", ""),
Twitter: getEnv("ABOUT_TWITTER", ""),
LinkedIn: getEnv("ABOUT_LINKEDIN", ""),
Mastodon: getEnv("ABOUT_MASTODON", ""),
Website: getEnv("ABOUT_WEBSITE", ""),
Avatar: getEnv("ABOUT_AVATAR", ""),
Tagline: getEnv("ABOUT_TAGLINE", ""),
Bio: getEnv("ABOUT_BIO", ""),
Location: getEnv("ABOUT_LOCATION", ""),
GitHub: getEnv("ABOUT_GITHUB", ""),
Twitter: getEnv("ABOUT_TWITTER", ""),
LinkedIn: getEnv("ABOUT_LINKEDIN", ""),
Mastodon: getEnv("ABOUT_MASTODON", ""),
Website: getEnv("ABOUT_WEBSITE", ""),
ReachHeading: getEnv("ABOUT_REACH_HEADING", "Reach out"),
EmailHeading: getEnv("ABOUT_EMAIL_HEADING", "Email"),
EmailIntro: getEnv("ABOUT_EMAIL_INTRO", "Or drop a line directly."),
},

Logging: LoggingConfig{
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ func TestLoad(t *testing.T) {
assert.Equal(t, "What would you like to know?", cfg.AMA.FormPlaceholder)
assert.Equal(t, "Submit Question", cfg.AMA.SubmitLabel)
assert.Equal(t, "Question submitted! It will appear once answered.", cfg.AMA.ThankyouCopy)

// Test About reach defaults — must be byte-exact to pre-v3.15.0 hardcoded
// template literals so /about renders identically when env unset.
assert.Equal(t, "Reach out", cfg.About.ReachHeading)
assert.Equal(t, "Email", cfg.About.EmailHeading)
assert.Equal(t, "Or drop a line directly.", cfg.About.EmailIntro)
}

func TestLoadWithEnvironmentVariables(t *testing.T) {
Expand Down Expand Up @@ -338,6 +344,7 @@ func clearEnvVars() {
"ENVIRONMENT", "PORT", "ARTICLES_PATH", "STATIC_PATH", "TEMPLATES_PATH", "BASE_URL",
"SERVER_READ_TIMEOUT", "SERVER_WRITE_TIMEOUT", "SERVER_IDLE_TIMEOUT", "SHUTDOWN_TIMEOUT",
"AMA_PAGE_HEADING", "AMA_PAGE_INTRO", "AMA_FORM_PLACEHOLDER", "AMA_SUBMIT_LABEL", "AMA_THANKYOU_COPY",
"ABOUT_REACH_HEADING", "ABOUT_EMAIL_HEADING", "ABOUT_EMAIL_INTRO",
"CACHE_TTL", "CACHE_MAX_SIZE", "CACHE_CLEANUP_INTERVAL",
"EMAIL_HOST", "EMAIL_PORT", "EMAIL_USERNAME", "EMAIL_PASSWORD", "EMAIL_FROM", "EMAIL_TO", "EMAIL_USE_SSL",
"RATE_LIMIT_GENERAL_REQUESTS", "RATE_LIMIT_GENERAL_WINDOW", "RATE_LIMIT_CONTACT_REQUESTS", "RATE_LIMIT_CONTACT_WINDOW", "RATE_LIMIT_UPLOAD_REQUESTS", "RATE_LIMIT_UPLOAD_WINDOW",
Expand Down
15 changes: 11 additions & 4 deletions internal/handlers/about_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@ func NewAboutHandler(
// 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)
// Reach: about_reach_heading (section h2),
// about_email_heading, about_email_intro (email card copy),
// about_ama_heading, about_ama_intro, about_ama_label
// (v3.14.0+ AMA half closes #75 — AMA copy from the existing
// v3.11.0 AMA_PAGE_* env vars so /ama and /about speak in the
// same operator voice; v3.15.0 reach/email keys close #78 —
// the other half of the reach card group, byte-exact defaults
// preserved when env vars are unset)
//
// Plus standard base-template keys via buildBaseTemplateData.
func (h *AboutHandler) ShowAbout(c *gin.Context) {
Expand Down Expand Up @@ -99,6 +103,9 @@ func (h *AboutHandler) ShowAbout(c *gin.Context) {
// 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_reach_heading"] = cfg.About.ReachHeading
data["about_email_heading"] = cfg.About.EmailHeading
data["about_email_intro"] = cfg.About.EmailIntro
data["about_ama_heading"] = cfg.AMA.PageHeading
data["about_ama_intro"] = cfg.AMA.PageIntro
data["about_ama_label"] = cfg.AMA.SubmitLabel
Expand Down
72 changes: 58 additions & 14 deletions internal/handlers/about_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ func TestAboutHandler_TemplateData(t *testing.T) {
PageIntro: "Curious about something?",
SubmitLabel: "Submit Question",
},
About: config.AboutConfig{
// v3.15.0 reach copy: tests assert pipes-through behavior;
// byte-exact defaults are guarded at config-load time
// (see internal/config/config_test.go).
ReachHeading: "Reach out",
EmailHeading: "Email",
EmailIntro: "Or drop a line directly.",
},
}
}

Expand All @@ -58,24 +66,33 @@ func TestAboutHandler_TemplateData(t *testing.T) {
wantHeading string
wantIntro string
wantLabel string
wantReachHeading string
wantEmailHeading string
wantEmailIntro string
}{
{
name: "defaults: AMA + mailto",
mutate: func(*config.Config) {},
wantHasContact: true,
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
name: "defaults: AMA + mailto",
mutate: func(*config.Config) {},
wantHasContact: true,
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
wantReachHeading: "Reach out",
wantEmailHeading: "Email",
wantEmailIntro: "Or drop a line directly.",
},
{
name: "no email — AMA still renders solo",
mutate: func(c *config.Config) {
c.Blog.AuthorEmail = ""
},
wantHasContact: false,
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
wantHasContact: false,
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
wantReachHeading: "Reach out",
wantEmailHeading: "Email",
wantEmailIntro: "Or drop a line directly.",
},
{
name: "custom AMA copy reaches template (operator voice)",
Expand All @@ -84,10 +101,31 @@ func TestAboutHandler_TemplateData(t *testing.T) {
c.AMA.PageIntro = "Got something on your mind?"
c.AMA.SubmitLabel = "Send it"
},
wantHasContact: true,
wantHeading: "Hit me up",
wantIntro: "Got something on your mind?",
wantLabel: "Send it",
wantHasContact: true,
wantHeading: "Hit me up",
wantIntro: "Got something on your mind?",
wantLabel: "Send it",
wantReachHeading: "Reach out",
wantEmailHeading: "Email",
wantEmailIntro: "Or drop a line directly.",
},
{
// v3.15.0 #78: operator overrides for the reach section's
// section heading + email card heading/intro must reach the
// template verbatim, alongside the AMA copy.
name: "custom reach copy reaches template (operator voice)",
mutate: func(c *config.Config) {
c.About.ReachHeading = "Get in touch"
c.About.EmailHeading = "Mail me"
c.About.EmailIntro = "I read everything."
},
wantHasContact: true,
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
wantReachHeading: "Get in touch",
wantEmailHeading: "Mail me",
wantEmailIntro: "I read everything.",
},
{
// Regression guard for the v3.14.0 PR #76 finding: AMA card
Expand All @@ -104,6 +142,9 @@ func TestAboutHandler_TemplateData(t *testing.T) {
wantHeading: "Ask me anything",
wantIntro: "Curious about something?",
wantLabel: "Submit Question",
wantReachHeading: "Reach out",
wantEmailHeading: "Email",
wantEmailIntro: "Or drop a line directly.",
},
}

Expand All @@ -128,6 +169,9 @@ func TestAboutHandler_TemplateData(t *testing.T) {
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, tt.wantReachHeading, mockTpl.LastData["about_reach_heading"], "operator voice — reach section heading reaches the template verbatim")
assert.Equal(t, tt.wantEmailHeading, mockTpl.LastData["about_email_heading"])
assert.Equal(t, tt.wantEmailIntro, mockTpl.LastData["about_email_intro"])
})
}
}
Expand Down
74 changes: 74 additions & 0 deletions internal/handlers/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ func NewComposeHandler(
}
}

// validatePageInput returns a human-readable error message if the input
// is not acceptable for a new type:page article, or empty string on
// success. Pages need (a) a non-empty title because the canonical URL
// surface (the /p index, browser tab, social previews) is title-driven,
// and (b) a slug that satisfies the strict ValidateSlug contract
// (charset, length, reserved set) and is unique within the article
// store. The handler renders the message in the error banner.
func (h *ComposeHandler) validatePageInput(input *compose.Input) string {
if strings.TrimSpace(input.Title) == "" {
return "Title is required for pages"
}
if err := articlepkg.ValidateSlug(input.Slug); err != nil {
return "Slug invalid: " + err.Error()
}
if existing, err := h.articleService.GetArticleBySlug(input.Slug); err == nil && existing != nil {
return "Slug already in use: " + input.Slug
}
return ""
}

// canonicalPathForSlug resolves the canonical URL for a composed or
// edited post by looking up the article in the in-memory store. Existing
// type:page articles edited via /compose/edit/:slug must redirect to
Expand Down Expand Up @@ -93,6 +113,18 @@ func (h *ComposeHandler) ShowCompose(c *gin.Context) {
h.renderHTML(c, http.StatusOK, "base.html", data)
}

// ShowComposeNewPage renders the compose form in page-authoring mode.
// Pages need an explicit slug and skip date/tags/categories — the template
// branches on data["mode"] == "page" to surface a slug input and hide
// the article-shaped fields.
func (h *ComposeHandler) ShowComposeNewPage(c *gin.Context) {
data := h.buildBaseTemplateData("New page - " + h.config.Blog.Title)
data["template"] = templateCompose
data["mode"] = articlepkg.TypePage
data["csrf_token"] = csrfToken(c)
h.renderHTML(c, http.StatusOK, "base.html", data)
}

// ShowEdit renders the compose form pre-filled with an existing article.
func (h *ComposeHandler) ShowEdit(c *gin.Context) {
slug := c.Param("slug")
Expand All @@ -112,6 +144,12 @@ func (h *ComposeHandler) ShowEdit(c *gin.Context) {
data["input"] = input
data["editing"] = true
data["slug"] = slug
data["canonicalPath"] = h.canonicalPathForSlug(slug)
if input.Type == articlepkg.TypePage {
// Surface page-mode so the template can hide article-only fields
// (link_url, tags, categories, banner) when editing a page.
data["mode"] = articlepkg.TypePage
}
data["csrf_token"] = csrfToken(c)
h.renderHTML(c, http.StatusOK, "base.html", data)
}
Expand Down Expand Up @@ -165,6 +203,7 @@ func (h *ComposeHandler) HandleEdit(c *gin.Context) {
Banner: c.PostForm("banner"),
BannerAlt: c.PostForm("banner_alt"),
Draft: c.PostForm("draft") == "on",
Type: c.PostForm("type"),
}

if input.Content == "" {
Expand All @@ -174,6 +213,10 @@ func (h *ComposeHandler) HandleEdit(c *gin.Context) {
data["input"] = input
data["editing"] = true
data["slug"] = slug
data["canonicalPath"] = h.canonicalPathForSlug(slug)
if input.Type == articlepkg.TypePage {
data["mode"] = articlepkg.TypePage
}
data["csrf_token"] = refreshCSRFToken(c)
if c.IsAborted() {
return
Expand All @@ -190,6 +233,10 @@ func (h *ComposeHandler) HandleEdit(c *gin.Context) {
data["input"] = input
data["editing"] = true
data["slug"] = slug
data["canonicalPath"] = h.canonicalPathForSlug(slug)
if input.Type == articlepkg.TypePage {
data["mode"] = articlepkg.TypePage
}
data["csrf_token"] = refreshCSRFToken(c)
if c.IsAborted() {
return
Expand Down Expand Up @@ -224,13 +271,18 @@ func (h *ComposeHandler) HandleSubmit(c *gin.Context) {
Banner: c.PostForm("banner"),
BannerAlt: c.PostForm("banner_alt"),
Draft: c.PostForm("draft") == "on",
Type: c.PostForm("type"),
Slug: c.PostForm("slug"),
}

if input.Content == "" {
data := h.buildBaseTemplateData("Compose - " + h.config.Blog.Title)
data["template"] = templateCompose
data["error"] = "Content is required"
data["input"] = input
if input.Type == articlepkg.TypePage {
data["mode"] = articlepkg.TypePage
}
data["csrf_token"] = refreshCSRFToken(c)
if c.IsAborted() {
return
Expand All @@ -239,13 +291,35 @@ func (h *ComposeHandler) HandleSubmit(c *gin.Context) {
return
}

// Page-mode validation: pages need a valid, unique slug. Run before
// CreatePost so the operator sees a render with their input intact
// rather than a generic 500.
if input.Type == articlepkg.TypePage {
if errMsg := h.validatePageInput(&input); errMsg != "" {
data := h.buildBaseTemplateData("New page - " + h.config.Blog.Title)
data["template"] = templateCompose
data["mode"] = articlepkg.TypePage
data["error"] = errMsg
data["input"] = input
data["csrf_token"] = refreshCSRFToken(c)
if c.IsAborted() {
return
}
h.renderHTML(c, http.StatusBadRequest, "base.html", data)
return
}
}

slug, err := h.composeService.CreatePost(&input)
if err != nil {
h.logger.Error("Failed to create post", "error", err)
data := h.buildBaseTemplateData("Compose - " + h.config.Blog.Title)
data["template"] = templateCompose
data["error"] = "Failed to create post. Please try again."
data["input"] = input
if input.Type == articlepkg.TypePage {
data["mode"] = articlepkg.TypePage
}
data["csrf_token"] = refreshCSRFToken(c)
if c.IsAborted() {
return
Expand Down
Loading