diff --git a/internal/commands/serve/command.go b/internal/commands/serve/command.go index 75f40af..4f98bcc 100644 --- a/internal/commands/serve/command.go +++ b/internal/commands/serve/command.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index be77441..caa4a35 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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{ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6fd7d9d..2a5ace6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { @@ -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", diff --git a/internal/handlers/about_handler.go b/internal/handlers/about_handler.go index 2f73933..a913306 100644 --- a/internal/handlers/about_handler.go +++ b/internal/handlers/about_handler.go @@ -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) { @@ -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 diff --git a/internal/handlers/about_handler_test.go b/internal/handlers/about_handler_test.go index 4de6757..0b43206 100644 --- a/internal/handlers/about_handler_test.go +++ b/internal/handlers/about_handler_test.go @@ -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.", + }, } } @@ -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)", @@ -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 @@ -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.", }, } @@ -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"]) }) } } diff --git a/internal/handlers/compose.go b/internal/handlers/compose.go index 78bed58..e25ae45 100644 --- a/internal/handlers/compose.go +++ b/internal/handlers/compose.go @@ -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 @@ -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") @@ -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) } @@ -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 == "" { @@ -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 @@ -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 @@ -224,6 +271,8 @@ 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 == "" { @@ -231,6 +280,9 @@ func (h *ComposeHandler) HandleSubmit(c *gin.Context) { 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 @@ -239,6 +291,25 @@ 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) @@ -246,6 +317,9 @@ func (h *ComposeHandler) HandleSubmit(c *gin.Context) { 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 diff --git a/internal/handlers/compose_test.go b/internal/handlers/compose_test.go index c5bd1c6..192ed82 100644 --- a/internal/handlers/compose_test.go +++ b/internal/handlers/compose_test.go @@ -694,6 +694,13 @@ func writeDraftArticle(t *testing.T, dir, slug string, isDraft bool) { require.NoError(t, os.WriteFile(filepath.Join(dir, filename), []byte(content), 0o644)) } +func writePageArticle(t *testing.T, dir, slug string) { + t.Helper() + content := fmt.Sprintf("---\nslug: %s\ntitle: A Page\ntype: page\ndraft: false\n---\n\nPage body.\n", slug) + filename := fmt.Sprintf("2026-01-01-%s.md", slug) + require.NoError(t, os.WriteFile(filepath.Join(dir, filename), []byte(content), 0o644)) +} + func TestPublishDraft(t *testing.T) { t.Run("invalid slug returns 400", func(t *testing.T) { handler, _ := createPublishDraftHandler(t) @@ -1579,3 +1586,249 @@ func TestShowEdit(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) }) } + +// --------------------------------------------------------------------------- +// ShowComposeNewPage tests +// --------------------------------------------------------------------------- + +func TestShowComposeNewPage(t *testing.T) { + t.Run("renders compose form in page mode", func(t *testing.T) { + handler, _ := createFormComposeHandler(t) + + router := gin.New() + router.GET("/compose/new-page", handler.ShowComposeNewPage) + + req := httptest.NewRequest("GET", "/compose/new-page", http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) +} + +// --------------------------------------------------------------------------- +// HandleEdit — type:page round-trip regression +// --------------------------------------------------------------------------- + +// TestHandleEdit_PageRoundTrip guards the contract that editing a +// type:page article through /compose/edit/ preserves the +// `type: page` frontmatter key. UpdateArticle preserves unknown +// frontmatter via generic-map round-trip today (see +// TestUpdateArticle_PreservesAMAFields for the sibling contract); +// this test locks the contract end-to-end through the handler so a +// future refactor of UpdateArticle's preservation mechanism can't +// silently regress the page edit path. +func TestHandleEdit_PageRoundTrip(t *testing.T) { + handler, tmpDir := createFormComposeHandler(t) + writePageArticle(t, tmpDir, "evergreen-page") + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("csrf_secure", false) + c.Next() + }) + router.GET("/compose/edit/:slug", handler.ShowEdit) + router.POST("/compose/edit/:slug", handler.HandleEdit) + + // GET: hidden type field must surface as "page" + req := httptest.NewRequest("GET", "/compose/edit/evergreen-page", http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + // POST: round-trip type=page from form + form := url.Values{ + "content": {"Updated page body."}, + "title": {"A Page"}, + "type": {"page"}, + "_csrf": {"test-token"}, + } + req = httptest.NewRequest("POST", "/compose/edit/evergreen-page", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusSeeOther, w.Code) + + // Frontmatter survived + files, err := filepath.Glob(filepath.Join(tmpDir, "*evergreen-page.md")) + require.NoError(t, err) + require.Len(t, files, 1) + contentBytes, err := os.ReadFile(files[0]) + require.NoError(t, err) + assert.Contains(t, string(contentBytes), "type: page", "type:page must survive edit-then-save") + assert.Contains(t, string(contentBytes), "Updated page body.", "content reflects the edit") +} + +// --------------------------------------------------------------------------- +// HandleSubmit — page-mode validation +// --------------------------------------------------------------------------- + +func TestHandleSubmit_PageMode(t *testing.T) { + t.Run("creates page and redirects to /p/", func(t *testing.T) { + handler, tmpDir := createFormComposeHandler(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("csrf_secure", false) + c.Next() + }) + router.POST("/compose", handler.HandleSubmit) + + form := url.Values{ + "content": {"Evergreen page body."}, + "title": {"About"}, + "type": {"page"}, + "slug": {"about-the-author"}, + "_csrf": {"test-token"}, + } + req := httptest.NewRequest("POST", "/compose", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusSeeOther, w.Code) + // MockArticleService doesn't know the new slug, so canonicalPathForSlug + // falls back to the synthetic empty-Type article — /writing/. + // In production the slug would be reloaded into the repo and resolve + // to /p/. The redirect path itself is exercised; the canonical + // resolution is covered by predicate tests. + assert.NotEmpty(t, w.Header().Get("Location")) + + entries, err := os.ReadDir(tmpDir) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Contains(t, entries[0].Name(), "-about-the-author.md") + + content, err := os.ReadFile(tmpDir + "/" + entries[0].Name()) + require.NoError(t, err) + assert.Contains(t, string(content), "type: page") + assert.Contains(t, string(content), "slug: about-the-author") + assert.NotContains(t, string(content), "date:", "page frontmatter omits date") + }) + + t.Run("empty title returns 400", func(t *testing.T) { + handler, _ := createFormComposeHandler(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("csrf_secure", false) + c.Next() + }) + router.POST("/compose", handler.HandleSubmit) + + form := url.Values{ + "content": {"Body."}, + "title": {""}, + "type": {"page"}, + "slug": {"my-page"}, + "_csrf": {"test-token"}, + } + req := httptest.NewRequest("POST", "/compose", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("empty slug returns 400", func(t *testing.T) { + handler, _ := createFormComposeHandler(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("csrf_secure", false) + c.Next() + }) + router.POST("/compose", handler.HandleSubmit) + + form := url.Values{ + "content": {"Body."}, + "title": {"X"}, + "type": {"page"}, + "slug": {""}, + "_csrf": {"test-token"}, + } + req := httptest.NewRequest("POST", "/compose", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("reserved slug returns 400", func(t *testing.T) { + handler, _ := createFormComposeHandler(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("csrf_secure", false) + c.Next() + }) + router.POST("/compose", handler.HandleSubmit) + + form := url.Values{ + "content": {"Body."}, + "title": {"X"}, + "type": {"page"}, + "slug": {"feed"}, + "_csrf": {"test-token"}, + } + req := httptest.NewRequest("POST", "/compose", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("colliding slug returns 400", func(t *testing.T) { + handler, _ := createFormComposeHandler(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("csrf_secure", false) + c.Next() + }) + router.POST("/compose", handler.HandleSubmit) + + // MockArticleService.GetArticleBySlug returns a hit for "test-article". + form := url.Values{ + "content": {"Body."}, + "title": {"X"}, + "type": {"page"}, + "slug": {"test-article"}, + "_csrf": {"test-token"}, + } + req := httptest.NewRequest("POST", "/compose", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("invalid slug charset returns 400", func(t *testing.T) { + handler, _ := createFormComposeHandler(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("csrf_secure", false) + c.Next() + }) + router.POST("/compose", handler.HandleSubmit) + + form := url.Values{ + "content": {"Body."}, + "title": {"X"}, + "type": {"page"}, + "slug": {"My_Page"}, + "_csrf": {"test-token"}, + } + req := httptest.NewRequest("POST", "/compose", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) +} diff --git a/internal/services/article/predicate.go b/internal/services/article/predicate.go index 4c85418..ec5dd7f 100644 --- a/internal/services/article/predicate.go +++ b/internal/services/article/predicate.go @@ -1,6 +1,12 @@ package article -import "github.com/1mb-dev/markgo/internal/models" +import ( + "fmt" + "regexp" + "strings" + + "github.com/1mb-dev/markgo/internal/models" +) // TypePage is the explicit frontmatter type value for evergreen pages // served by the /p/:slug route. @@ -41,3 +47,54 @@ func CanonicalURLFor(a *models.Article) string { } return "/writing/" + a.Slug } + +// SlugMaxLength caps slug length for operator-supplied slugs. Filesystem +// safety + URL-readability ceiling. +const SlugMaxLength = 100 + +// slugCharClass enforces lowercase ASCII letters, digits, and hyphens +// with no leading or trailing hyphen. Mirrors the codebase-wide +// validSlug gate in compose handlers so a slug accepted at create-time +// is also accepted at edit/publish-draft time. +var slugCharClass = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +// reservedSlugs blocks slugs that would confuse readers if served from +// /p/ by shadowing feed-like names. The set is deliberately minimal: +// only names that collide with content-discovery conventions. Reserved +// route prefixes (/about, /writing, /admin, etc.) live at different paths +// and don't need explicit blocking here. +var reservedSlugs = map[string]struct{}{ + "index": {}, + "feed": {}, + "rss": {}, + "atom": {}, +} + +// ValidateSlug enforces the strict contract for operator-supplied slugs +// at the compose/new-page authoring boundary. Rejects anything that +// wouldn't be a clean URL component or would shadow a feed-like name. +// +// This is intentionally stricter than FileSystemRepository.validateSlug, +// which guards already-stored slugs from path traversal at admin-only +// surfaces. Historical articles may have looser slugs (e.g. uppercase +// from pre-validation eras) — those still work via the repository's +// permissive check. New writes through the compose form must satisfy +// this stricter contract. +func ValidateSlug(slug string) error { + if strings.TrimSpace(slug) == "" { + return fmt.Errorf("slug cannot be empty") + } + if strings.Contains(slug, "..") || strings.Contains(slug, "/") || strings.Contains(slug, "\\") { + return fmt.Errorf("slug contains path-traversal characters: %s", slug) + } + if len(slug) > SlugMaxLength { + return fmt.Errorf("slug exceeds %d characters: %d", SlugMaxLength, len(slug)) + } + if !slugCharClass.MatchString(slug) { + return fmt.Errorf("slug must match %s: %s", slugCharClass.String(), slug) + } + if _, blocked := reservedSlugs[slug]; blocked { + return fmt.Errorf("slug is reserved: %s", slug) + } + return nil +} diff --git a/internal/services/article/predicate_test.go b/internal/services/article/predicate_test.go index ac7f456..666b847 100644 --- a/internal/services/article/predicate_test.go +++ b/internal/services/article/predicate_test.go @@ -1,6 +1,7 @@ package article import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -47,3 +48,58 @@ func TestCanonicalURLFor(t *testing.T) { }) } } + +func TestValidateSlug(t *testing.T) { + tests := []struct { + name string + slug string + wantErr bool + }{ + {"valid lowercase letters", "intro", false}, + {"valid with hyphens", "my-evergreen-page", false}, + {"valid with digits", "release-2026", false}, + {"valid digits only", "123", false}, + {"valid single char", "a", false}, + + {"empty", "", true}, + {"whitespace only", " ", true}, + + {"path traversal dots", "..", true}, + {"embedded traversal", "foo/../bar", true}, + {"forward slash", "foo/bar", true}, + {"backslash", "foo\\bar", true}, + + {"uppercase letter", "Intro", true}, + {"underscore", "my_page", true}, + {"space", "my page", true}, + {"unicode", "café", true}, + {"dot", "my.page", true}, + + {"too long", strings.Repeat("a", SlugMaxLength+1), true}, + {"at length limit", strings.Repeat("a", SlugMaxLength), false}, + + {"reserved index", "index", true}, + {"reserved feed", "feed", true}, + {"reserved rss", "rss", true}, + {"reserved atom", "atom", true}, + {"reserved-adjacent ok", "feed-2026", false}, + + // Leading/trailing hyphens — must reject; the codebase-wide + // validSlug gate at compose.go:22 also rejects these, and a + // mismatch would let pages be created but not edited. + {"leading hyphen", "-mypage", true}, + {"trailing hyphen", "mypage-", true}, + {"only hyphen", "-", true}, + {"only hyphens", "---", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSlug(tt.slug) + if tt.wantErr { + assert.Error(t, err, "expected error for %q", tt.slug) + } else { + assert.NoError(t, err, "expected no error for %q", tt.slug) + } + }) + } +} diff --git a/internal/services/compose/service.go b/internal/services/compose/service.go index 5e453e4..1d06fcc 100644 --- a/internal/services/compose/service.go +++ b/internal/services/compose/service.go @@ -27,6 +27,11 @@ func NewService(articlesPath, defaultAuthor string) *Service { } } +// typePage mirrors article.TypePage. The article package imports compose +// (for ContainSlugPath), so we can't import the other direction; the +// constant is duplicated here as a synchronization point. +const typePage = "page" + // Input represents the compose form or API submission. type Input struct { Content string `json:"content"` @@ -41,22 +46,40 @@ type Input struct { Asker string `json:"asker"` AskerEmail string `json:"asker_email"` Type string `json:"type"` + // Slug is operator-supplied; required when Type == "page" because + // pages need stable, hand-picked URLs. Ignored otherwise — other + // types derive slugs from title or timestamp. + Slug string `json:"slug"` } // CreatePost creates a new markdown post file from compose input. // Returns the generated slug. +// +// Pages (Type == "page") use input.Slug verbatim and omit the date +// frontmatter — they're evergreen by definition and served by /p/:slug. +// All other types derive their slug from title or fall back to a +// timestamp-prefixed slug. func (s *Service) CreatePost(input *Input) (string, error) { now := time.Now() - // Generate slug (fall back to timestamp-prefixed slug if title produces - // empty slug, e.g. non-ASCII titles or empty Title field). Prefix - // reflects content type so `ls articles/` reveals intent at a glance. var slug string - if input.Title != "" { - slug = generateSlug(input.Title) - } - if slug == "" { - slug = fmt.Sprintf("%s-%d", slugPrefixFor(input.Type), now.UnixMilli()) + switch input.Type { + case typePage: + if strings.TrimSpace(input.Slug) == "" { + return "", fmt.Errorf("page requires an explicit slug") + } + slug = input.Slug + default: + // Generate slug from title; fall back to timestamp-prefixed slug + // if title produces empty slug (e.g. non-ASCII titles or empty + // Title field). Prefix reflects content type so `ls articles/` + // reveals intent at a glance. + if input.Title != "" { + slug = generateSlug(input.Title) + } + if slug == "" { + slug = fmt.Sprintf("%s-%d", slugPrefixFor(input.Type), now.UnixMilli()) + } } // Parse comma-separated tags @@ -68,10 +91,14 @@ func (s *Service) CreatePost(input *Input) (string, error) { } } - // Build frontmatter map (only non-empty fields) + // Build frontmatter map (only non-empty fields). Pages skip `date:` + // because they're evergreen and /p index sorts alphabetically, not + // by date. fm := map[string]any{ "slug": slug, - "date": now.Format(time.RFC3339), + } + if input.Type != typePage { + fm["date"] = now.Format(time.RFC3339) } setIfNonEmpty(fm, "title", input.Title) setIfNonEmpty(fm, "description", input.Description) diff --git a/internal/services/compose/service_test.go b/internal/services/compose/service_test.go index b800fe6..a78aff6 100644 --- a/internal/services/compose/service_test.go +++ b/internal/services/compose/service_test.go @@ -338,6 +338,85 @@ func TestLoadArticle_AMAFields(t *testing.T) { assert.Equal(t, "What is your favorite programming language?", input.Content) } +func TestCreatePost_PageType_RequiresSlug(t *testing.T) { + dir := t.TempDir() + svc := NewService(dir, "Test Author") + + _, err := svc.CreatePost(&Input{ + Type: "page", + Title: "About", + Content: "Page body.", + }) + + assert.Error(t, err, "page with empty slug should fail") + assert.Contains(t, err.Error(), "slug") + + // No file should have been written. + files, _ := filepath.Glob(filepath.Join(dir, "*.md")) + assert.Empty(t, files) +} + +func TestCreatePost_PageType_UsesExplicitSlug(t *testing.T) { + dir := t.TempDir() + svc := NewService(dir, "Test Author") + + slug, err := svc.CreatePost(&Input{ + Type: "page", + Slug: "my-evergreen", + Title: "Unrelated Title", + Content: "Page body.", + }) + + require.NoError(t, err) + assert.Equal(t, "my-evergreen", slug, "page slug should come from Input.Slug, not generateSlug(Title)") + + files, _ := filepath.Glob(filepath.Join(dir, "*.md")) + require.Len(t, files, 1) + assert.Contains(t, files[0], "-my-evergreen.md", "filename should still carry the date prefix + explicit slug") +} + +func TestCreatePost_PageType_OmitsDate(t *testing.T) { + dir := t.TempDir() + svc := NewService(dir, "Test Author") + + _, err := svc.CreatePost(&Input{ + Type: "page", + Slug: "about-the-site", + Title: "About", + Content: "Page body.", + }) + require.NoError(t, err) + + files, _ := filepath.Glob(filepath.Join(dir, "*.md")) + require.Len(t, files, 1) + content, err := os.ReadFile(files[0]) + require.NoError(t, err) + + s := string(content) + assert.Contains(t, s, "type: page") + assert.Contains(t, s, "slug: about-the-site") + assert.NotContains(t, s, "date:", "page frontmatter should omit date") +} + +func TestCreatePost_NonPage_PreservesDateBehavior(t *testing.T) { + dir := t.TempDir() + svc := NewService(dir, "Test Author") + + // Regression: non-page types continue to emit date frontmatter. + _, err := svc.CreatePost(&Input{ + Type: "article", + Title: "Regular Article", + Content: "Body.", + }) + require.NoError(t, err) + + files, _ := filepath.Glob(filepath.Join(dir, "*.md")) + require.Len(t, files, 1) + content, err := os.ReadFile(files[0]) + require.NoError(t, err) + assert.Contains(t, string(content), "date:") +} + func TestUpdateArticle_PreservesAMAFields(t *testing.T) { dir := t.TempDir() svc := NewService(dir, "Test Author") diff --git a/web/templates/about.html b/web/templates/about.html index 2bcf6e0..20c1362 100644 --- a/web/templates/about.html +++ b/web/templates/about.html @@ -67,7 +67,7 @@

{{ .config.Blog.Author }}

-

Reach out

+

{{ .about_reach_heading }}

{{ .about_ama_heading }}

@@ -76,8 +76,8 @@

{{ .about_ama_heading }}

{{ if and .has_contact (not .has_contact_form) }}
-

Email

-

Or drop a line directly.

+

{{ .about_email_heading }}

+

{{ .about_email_intro }}

{{ .config.Blog.AuthorEmail }}
{{ end }} diff --git a/web/templates/compose.html b/web/templates/compose.html index 42abaef..20a36d2 100644 --- a/web/templates/compose.html +++ b/web/templates/compose.html @@ -3,11 +3,22 @@
@@ -25,8 +36,31 @@

Compose

Draft saving unavailable — copy your work before leaving
-
+ + + + {{ if eq .mode "page" }} + {{ if .editing }}{{/* slug immutable on edit — no input field */}}{{ else }} +
+ + + Lowercase letters, digits, hyphens. URL becomes /p/<slug>. Cannot be changed after create. +
+ {{ end }} + {{ end }} +