Prechádzať zdrojové kódy

Add About page, configurable Home link, RSS footer, MIT license

- Header: "Home" link (text set by homeText in content/site.json,
  default Home) replaces the host name; About replaces RSS.
- /about shows version, commit, post count and latest post, followed by
  the guide to writing posts (about.md, embedded).
- RSS link moves to the bottom right of a new footer.
- site.json is optional and reloaded on change; invalid JSON or unknown
  settings are logged and the last good settings are kept.
- Version 0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 2 dní pred
rodič
commit
ac317db16e
12 zmenil súbory, kde vykonal 426 pridanie a 7 odobranie
  1. 21 0
      LICENSE
  2. 32 2
      README.md
  3. 77 0
      about.md
  4. 87 0
      config.go
  5. 38 0
      config_test.go
  6. 29 1
      main.go
  7. 50 0
      main_test.go
  8. 9 1
      render.go
  9. 25 1
      static/style.css
  10. 19 0
      templates/about.html
  11. 5 2
      templates/layout.html
  12. 34 0
      version.go

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 bajdev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 32 - 2
README.md

@@ -37,7 +37,8 @@ Posts go through the normal branch-and-PR flow; never push to `main`.
 |---|---|
 | `/` | All posts, newest first |
 | `/posts/<slug>` | One post, with previous/next links |
-| `/feed.xml` | RSS feed |
+| `/about` | Version, post count, and how to write a post |
+| `/feed.xml` | RSS feed (linked in the footer) |
 | anything else | 404 page |
 
 Markdown is rendered with [goldmark](https://github.com/yuin/goldmark)
@@ -46,12 +47,33 @@ posts is not rendered.
 
 ## Configuration
 
+### Site settings: `content/site.json`
+
+Optional. Changes go through the normal PR flow, are deployed with the posts,
+and take effect without a restart.
+
+```json
+{
+  "homeText": "Home"
+}
+```
+
+| Setting | Default | Meaning |
+|---|---|---|
+| `homeText` | `Home` | Text of the header link to the index page |
+
+A missing file, a missing setting or a blank value uses the default. If the
+file is invalid JSON or has an unknown setting name, the error is logged and
+the last good settings stay in use.
+
+### Server settings
+
 Environment variables (on the server, in `/etc/goapps/posts.env`):
 
 | Variable | Default | Meaning |
 |---|---|---|
 | `PORT` | (required) | Port to listen on, bound to `127.0.0.1` |
-| `CONTENT_DIR` | `content` | Directory holding the `.md` posts |
+| `CONTENT_DIR` | `content` | Directory holding the `.md` posts and `site.json` |
 | `SITE_URL` | `https://posts.monoaf.com` | Absolute base URL used in the RSS feed |
 
 ## Development
@@ -61,6 +83,10 @@ go test ./...
 PORT=8080 go run .
 ```
 
+The About page text is `about.md`, built into the binary. The version shown
+there is the `version` constant in `version.go`; bump it in any PR that makes
+a user-visible change. The git commit is added automatically at build time.
+
 ## Deployment
 
 Runs as systemd unit `goapp@posts` on port 8002 behind nginx. Deploy with:
@@ -71,3 +97,7 @@ deploy-go posts --content
 
 which builds and installs the binary and syncs `content/` to
 `/opt/apps/posts/content/`.
+
+## License
+
+[MIT](LICENSE)

+ 77 - 0
about.md

@@ -0,0 +1,77 @@
+This site is a folder of Markdown files. Each file in `content/` becomes a
+post, and the newest one is listed first.
+
+## The file name
+
+Name each post `YYYY-MM-DD-slug.md`, for example `2026-10-24-hello-world.md`.
+
+- The **slug** is the part after the date. It becomes the post's address:
+  that file is served at `/posts/hello-world`.
+- Use lowercase letters, digits and hyphens only. A file with any other name
+  is skipped.
+- The date in the name is the post's date unless the header says otherwise.
+
+## The header block
+
+A post can start with an optional header block (often called *front matter*)
+between two `---` lines. For example:
+
+```markdown
+---
+title: Hello, world
+summary: The first post, and a guide to writing one.
+---
+```
+
+Each line is `key: value`. Four keys are recognised:
+
+| Key | What it does | If left out |
+|---|---|---|
+| `title` | The post's title, shown on the index page, at the top of the post and in the RSS feed | The first line of the post if it is a `# Heading` (that heading is then not repeated in the body); otherwise the slug |
+| `date` | The post's date, as `YYYY-MM-DD`. Posts are listed newest first | The date in the file name |
+| `summary` | One line shown under the title on the index page and used as the RSS description | Nothing is shown |
+| `draft` | Set to `true` to hide the post everywhere: index, post page and feed | `false` |
+
+Some details:
+
+- The block must be the very first thing in the file, and it must be closed
+  with a second `---` line.
+- Values can be wrapped in quotes, which are removed: `title: "Hello, world"`.
+- Lines starting with `#` inside the block are comments.
+- Keys other than the four above are ignored.
+- A post with a broken header, such as an unknown date format or a missing
+  closing `---`, is skipped and the problem is logged. The rest of the site is
+  unaffected.
+
+The header is optional. This is a complete post:
+
+```markdown
+# A short note
+
+Just a paragraph.
+```
+
+Its title is "A short note" and its date comes from the file name.
+
+## Publishing
+
+Posts are reviewed like code: add the file on a branch, open a pull request,
+and the post goes live once it is merged and deployed. To hold a post back
+after merging, set `draft: true`.
+
+## What Markdown works
+
+- **Bold**, *italic*, ~~strikethrough~~ and `inline code`
+- Links like https://posts.monoaf.com are linked automatically
+- Headings, lists, quotes, images, tables, task lists and fenced code blocks
+
+| Feature | Supported |
+|---|---|
+| Tables | yes |
+| RSS | yes, at `/feed.xml` |
+
+```go
+fmt.Println("hello, world")
+```
+
+> Raw HTML in a post is left out, so posts can only contain Markdown.

+ 87 - 0
config.go

@@ -0,0 +1,87 @@
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/fs"
+	"log"
+	"os"
+	"strings"
+	"sync"
+)
+
+// siteConfig holds the settings in site.json. Every field is optional.
+type siteConfig struct {
+	HomeText string `json:"homeText"` // text of the header link to the index
+}
+
+// withDefaults fills in any setting that is missing or blank.
+func (c siteConfig) withDefaults() siteConfig {
+	if strings.TrimSpace(c.HomeText) == "" {
+		c.HomeText = "Home"
+	}
+	return c
+}
+
+// configFile reads site.json and re-reads it when its size or modification
+// time changes. A missing file means all defaults; a broken file is logged
+// and the last good settings stay in use.
+type configFile struct {
+	path string
+
+	mu     sync.Mutex
+	sig    string
+	loaded bool
+	cfg    siteConfig
+}
+
+func newConfigFile(path string) *configFile {
+	return &configFile{path: path, cfg: siteConfig{}.withDefaults()}
+}
+
+func (c *configFile) Get() siteConfig {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	sig := "missing"
+	info, err := os.Stat(c.path)
+	switch {
+	case err == nil:
+		sig = fmt.Sprintf("%d %d", info.Size(), info.ModTime().UnixNano())
+	case !errors.Is(err, fs.ErrNotExist):
+		log.Printf("%s: %v; keeping previous settings", c.path, err)
+		return c.cfg
+	}
+	if c.loaded && sig == c.sig {
+		return c.cfg
+	}
+	c.sig, c.loaded = sig, true
+
+	if sig == "missing" {
+		c.cfg = siteConfig{}.withDefaults()
+		return c.cfg
+	}
+	cfg, err := readConfig(c.path)
+	if err != nil {
+		log.Printf("%s: %v; keeping previous settings", c.path, err)
+		return c.cfg
+	}
+	c.cfg = cfg
+	return c.cfg
+}
+
+func readConfig(path string) (siteConfig, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return siteConfig{}, err
+	}
+	defer f.Close()
+	dec := json.NewDecoder(f)
+	dec.DisallowUnknownFields() // catch typos in setting names
+	var cfg siteConfig
+	if err := dec.Decode(&cfg); err != nil {
+		return siteConfig{}, err
+	}
+	return cfg.withDefaults(), nil
+}

+ 38 - 0
config_test.go

@@ -0,0 +1,38 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestConfigFile(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "site.json")
+	c := newConfigFile(path)
+
+	steps := []struct {
+		name, json string // json "" means delete the file
+		want       string
+	}{
+		{"missing file", "", "Home"},
+		{"value set", `{"homeText": "Start"}`, "Start"},
+		{"field left out", `{}`, "Home"},
+		{"blank value", `{"homeText": "  "}`, "Home"},
+		{"set again", `{"homeText": "Posts"}`, "Posts"},
+		{"broken JSON keeps last good", `{"homeText": `, "Posts"},
+		{"unknown field keeps last good", `{"homeTxt": "Typo"}`, "Posts"},
+		{"fixed", `{"homeText": "Index page"}`, "Index page"},
+		{"deleted again", "", "Home"},
+	}
+	for _, s := range steps {
+		if s.json == "" {
+			os.Remove(path)
+		} else if err := os.WriteFile(path, []byte(s.json), 0o644); err != nil {
+			t.Fatal(err)
+		}
+		if got := c.Get().HomeText; got != s.want {
+			t.Errorf("%s: HomeText = %q, want %q", s.name, got, s.want)
+		}
+	}
+}

+ 29 - 1
main.go

@@ -2,21 +2,26 @@
 package main
 
 import (
+	"bytes"
 	"html/template"
 	"io/fs"
 	"log"
 	"net/http"
 	"net/url"
 	"os"
+	"path/filepath"
 	"strings"
 	"time"
 )
 
 type app struct {
 	store    *Store
+	config   *configFile
 	pages    map[string]*template.Template
+	about    template.HTML
 	siteURL  string // absolute base URL, no trailing slash
 	siteName string
+	commit   string
 }
 
 func newApp(contentDir, siteURL string) (*app, error) {
@@ -29,11 +34,19 @@ func newApp(contentDir, siteURL string) (*app, error) {
 	if err != nil {
 		return nil, err
 	}
+	md := newMarkdown()
+	var about bytes.Buffer
+	if err := md.Convert(aboutMarkdown, &about); err != nil {
+		return nil, err
+	}
 	return &app{
-		store:    NewStore(contentDir, newMarkdown()),
+		store:    NewStore(contentDir, md),
+		config:   newConfigFile(filepath.Join(contentDir, "site.json")),
 		pages:    pages,
+		about:    template.HTML(about.String()),
 		siteURL:  siteURL,
 		siteName: u.Host,
+		commit:   buildCommit(),
 	}, nil
 }
 
@@ -47,6 +60,7 @@ func (a *app) routes() http.Handler {
 	mux := http.NewServeMux()
 	mux.HandleFunc("GET /{$}", a.index)
 	mux.HandleFunc("GET /posts/{slug}", a.post)
+	mux.HandleFunc("GET /about", a.aboutPage)
 	mux.HandleFunc("GET /feed.xml", a.feed)
 	mux.HandleFunc("GET /static/", func(w http.ResponseWriter, r *http.Request) {
 		if strings.HasSuffix(r.URL.Path, "/") { // no directory listings
@@ -90,6 +104,20 @@ func (a *app) post(w http.ResponseWriter, r *http.Request) {
 	a.render(w, http.StatusOK, "post", data)
 }
 
+func (a *app) aboutPage(w http.ResponseWriter, r *http.Request) {
+	idx, err := a.store.Index()
+	if err != nil {
+		a.serverError(w, err)
+		return
+	}
+	a.render(w, http.StatusOK, "about", pageData{
+		Posts:   idx.posts,
+		Version: version,
+		Commit:  a.commit,
+		About:   a.about,
+	})
+}
+
 func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
 	a.render(w, http.StatusNotFound, "error", pageData{Status: 404, Message: "That page doesn't exist."})
 }

+ 50 - 0
main_test.go

@@ -165,6 +165,56 @@ func TestFeed(t *testing.T) {
 	}
 }
 
+func TestHeaderAndFooter(t *testing.T) {
+	srv, dir := newTestServer(t, sample)
+	for _, path := range []string{"/", "/posts/middle", "/about", "/nope"} {
+		_, _, body := get(t, srv, path)
+		for _, want := range []string{
+			`<a class="home" href="/">Home</a>`,
+			`<nav><a href="/about">About</a></nav>`,
+			"<footer class=\"site\">\n  <a href=\"/feed.xml\">RSS</a>\n</footer>",
+		} {
+			if !strings.Contains(body, want) {
+				t.Errorf("%s: missing %q", path, want)
+			}
+		}
+	}
+
+	writeFile(t, dir, "site.json", `{"homeText": "All <posts>"}`)
+	if _, _, body := get(t, srv, "/"); !strings.Contains(body, `<a class="home" href="/">All &lt;posts&gt;</a>`) {
+		t.Errorf("configured home text not used (or not escaped):\n%s", body)
+	}
+	if status, _, _ := get(t, srv, "/posts/site"); status != 404 {
+		t.Error("site.json should not be treated as a post")
+	}
+}
+
+func TestAboutPage(t *testing.T) {
+	srv, _ := newTestServer(t, sample)
+	status, _, body := get(t, srv, "/about")
+	if status != 200 {
+		t.Fatalf("status = %d", status)
+	}
+	for _, want := range []string{
+		"<title>About · posts.example.com</title>",
+		"<dt>Version</dt>\n    <dd>" + version,
+		"<dt>Posts</dt>\n    <dd>3</dd>",
+		`<dd><a href="/posts/newest">Newest</a>`,
+		`<h2 id="the-header-block">The header block</h2>`,
+		`<h2 id="what-markdown-works">What Markdown works</h2>`,
+	} {
+		if !strings.Contains(body, want) {
+			t.Errorf("missing %q in:\n%s", want, body)
+		}
+	}
+
+	empty, _ := newTestServer(t, nil)
+	_, _, body = get(t, empty, "/about")
+	if !strings.Contains(body, "<dd>0</dd>") || strings.Contains(body, "Latest post") {
+		t.Errorf("empty site: expected 0 posts and no latest post:\n%s", body)
+	}
+}
+
 func TestReloadsWhenContentChanges(t *testing.T) {
 	srv, dir := newTestServer(t, map[string]string{"2026-01-01-one.md": "# One\n"})
 	if _, _, body := get(t, srv, "/"); strings.Contains(body, "Two") {

+ 9 - 1
render.go

@@ -18,6 +18,8 @@ var (
 	templateFS embed.FS
 	//go:embed static
 	staticFS embed.FS
+	//go:embed about.md
+	aboutMarkdown []byte
 )
 
 // newMarkdown returns a CommonMark renderer with GitHub-style extensions.
@@ -32,11 +34,16 @@ func newMarkdown() goldmark.Markdown {
 // pageData is passed to every template.
 type pageData struct {
 	Site         string
+	Config       siteConfig
 	Posts        []*Post
 	Post         *Post
 	Newer, Older *Post
 	Status       int
 	Message      string
+
+	// About page only.
+	Version, Commit string
+	About           template.HTML
 }
 
 // loadTemplates parses each page template together with the shared layout.
@@ -50,7 +57,7 @@ func loadTemplates() (map[string]*template.Template, error) {
 		return nil, err
 	}
 	pages := map[string]*template.Template{}
-	for _, name := range []string{"index", "post", "error"} {
+	for _, name := range []string{"index", "post", "about", "error"} {
 		t, err := base.Clone()
 		if err != nil {
 			return nil, err
@@ -66,6 +73,7 @@ func loadTemplates() (map[string]*template.Template, error) {
 // clean 500 instead of a half-written page.
 func (a *app) render(w http.ResponseWriter, status int, page string, data pageData) {
 	data.Site = a.siteName
+	data.Config = a.config.Get()
 	var buf bytes.Buffer
 	if err := a.pages[page].ExecuteTemplate(&buf, "layout.html", data); err != nil {
 		log.Printf("render %s: %v", page, err)

+ 25 - 1
static/style.css

@@ -43,7 +43,31 @@ header.site {
 header.site .home { color: var(--fg); font-weight: 600; text-decoration: none; }
 header.site nav a { color: var(--muted); font-size: 0.9rem; }
 
-main { padding-bottom: 4rem; }
+main { padding-bottom: 3rem; }
+
+footer.site {
+  max-width: 42rem;
+  margin: 0 auto;
+  padding: 1rem 1rem 2rem;
+  border-top: 1px solid var(--rule);
+  display: flex;
+  justify-content: flex-end;
+}
+footer.site a { color: var(--muted); font-size: 0.9rem; }
+
+.muted { color: var(--muted); }
+.facts {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: 0.25rem 1.25rem;
+  margin: 1.5rem 0 2rem;
+  padding: 1rem 1.25rem;
+  border: 1px solid var(--rule);
+  border-radius: 4px;
+}
+.facts dt { color: var(--muted); }
+.facts dd { margin: 0; }
+.facts time { margin-left: 0.5rem; }
 time { color: var(--muted); font-size: 0.9rem; }
 
 .post-list { list-style: none; padding: 0; margin: 0; }

+ 19 - 0
templates/about.html

@@ -0,0 +1,19 @@
+{{define "title"}}About · {{.Site}}{{end}}
+{{define "content"}}
+<article>
+  <h1>About</h1>
+  <dl class="facts">
+    <dt>Version</dt>
+    <dd>{{.Version}}{{with .Commit}} <span class="muted">({{.}})</span>{{end}}</dd>
+    <dt>Posts</dt>
+    <dd>{{len .Posts}}</dd>
+    {{with .Posts}}{{with index . 0}}
+    <dt>Latest post</dt>
+    <dd><a href="/posts/{{.Slug}}">{{.Title}}</a> <time datetime="{{isodate .Date}}">{{date .Date}}</time></dd>
+    {{end}}{{end}}
+  </dl>
+  <div class="body">
+{{.About}}
+  </div>
+</article>
+{{end}}

+ 5 - 2
templates/layout.html

@@ -9,11 +9,14 @@
 </head>
 <body>
 <header class="site">
-  <a class="home" href="/">{{.Site}}</a>
-  <nav><a href="/feed.xml">RSS</a></nav>
+  <a class="home" href="/">{{.Config.HomeText}}</a>
+  <nav><a href="/about">About</a></nav>
 </header>
 <main>
 {{template "content" .}}
 </main>
+<footer class="site">
+  <a href="/feed.xml">RSS</a>
+</footer>
 </body>
 </html>

+ 34 - 0
version.go

@@ -0,0 +1,34 @@
+package main
+
+import "runtime/debug"
+
+// version is the release number shown on the About page. Bump it in the PR
+// that makes a user-visible change.
+const version = "0.2.0"
+
+// buildCommit returns the short git revision that `go build` stamped into the
+// binary, with "-dirty" if the tree had uncommitted changes, or "" if the
+// binary was built outside a git checkout.
+func buildCommit() string {
+	info, ok := debug.ReadBuildInfo()
+	if !ok {
+		return ""
+	}
+	var rev string
+	var dirty bool
+	for _, s := range info.Settings {
+		switch s.Key {
+		case "vcs.revision":
+			rev = s.Value
+		case "vcs.modified":
+			dirty = s.Value == "true"
+		}
+	}
+	if len(rev) > 7 {
+		rev = rev[:7]
+	}
+	if rev != "" && dirty {
+		rev += "-dirty"
+	}
+	return rev
+}