diff options
28 files changed, 588 insertions, 71 deletions
@@ -101,7 +101,11 @@ internal/web/ server.go (host router, middleware, render helpers) top-level management path must be added to `reservedPageSlugs` (`handlers_pages.go`) and to `TestRootRoutePrecedence`. `/static/{file}` is a single segment on purpose (a `/static/` prefix pattern conflicts - with `/{page}/{post}`); static files must stay flat. + with `/{page}/{post}`); static files must stay flat. The public + `GET /tag/{tag}` is a literal first segment, so it beats `/{page}/{post}` + on every host (`tag` is reserved too). A `{page}` wildcard never matches + a trailing slash, so listings put `?p=N` straight after `base` (`/`, + `/news`, `/tag/go`) — `/news/?p=2` would be a 404. - **Auth**: login lives only at `/webadmin` on the root domain (deliberately not `/login`, and not linked from public pages); `/webadmin` on a blog host redirects there with `next=/b/<sub>/`. HS256 JWT in `session` cookie (`HttpOnly`, `SameSite=Lax`, @@ -151,12 +155,13 @@ internal/web/ server.go (host router, middleware, render helpers) `handlers_layout.go`, `/b/{sub}/layout…`): a blog page is six areas — `header`, `left`, `above`, `below` (the two custom-HTML slots inside the main column, one module at most), `right`, `footer` — each an ordered - list of modules (`kind`: title, logo, menu, archive, recent, html, rss, - text, sitemap; `moduleKinds` says which kinds an area accepts). Modules - have an optional `title` (heading), `body` (raw HTML or footer text) and - `count` (recent posts). `blogView` builds a `Layout` (`buildLayout`, - areas that are switched off are dropped) and only fetches the archive - index / recent posts when a visible module needs them. A side column is + list of modules (`kind`: title, logo, menu, archive, recent, tags, + tagcloud, html, rss, text, sitemap; `moduleKinds` says which kinds an area + accepts). Modules have an optional `title` (heading), `body` (raw HTML or + footer text) and `count` (recent posts; tags to list, 0 = all). `blogView` + builds a `Layout` (`buildLayout`, areas that are switched off are dropped) + and only fetches the archive index / recent posts / tag counts when a + visible module needs them (`NeedsArchive/MaxRecent/NeedsTags`). A side column is laid out only when it has modules, or always with `theme.keep_columns` (`HasLeft/HasRight` → `body.has-*` classes; column widths in the theme CSS under `@media (min-width: 701px)`). @@ -268,8 +273,26 @@ internal/web/ server.go (host router, middleware, render helpers) drives ordering, the archive, the feed and the displayed dates. Blank keeps the current value; a new post starts at now. Times are in the server's zone, which is also the one dates are rendered in. +- **Tags** (`tags` + `post_tags` tables, `store/tags.go`, `web/tags.go`): + a tag is `name` + unique `slug` (`slug.Make(name)`), so the slug is both + the URL (`/tag/<slug>`, `handleBlogTag`, published posts of every page, + paginated) and the dedupe key — "Go" and "go" are one tag, the first + spelling wins. `Post.Tags` rides along in `postCols` as two arrays. + The post form offers the blog's existing tags as checkboxes (`tag`) plus + a comma-separated `new_tags` box; `parseTags` normalises both into one + list (20 per post, 40 runes each; nothing slug-worthy → dropped) and + `SetPostTags` stores it after the post is saved. Tags no post carries + are deleted (`deleteOrphanTags`, also after post/page deletion), so the + form only lists tags in use. `TagCounts` counts published posts only: + a tag on hidden posts alone shows nowhere public and its page is a 404. + Tags render under the post date (`posttags` in `partials/postlist.html`, + which also holds the article loop + pager shared by `blog/page.html` + and `blog/tag.html`); the two modules are `tags` (by use, `count`) and + `tagcloud` (alphabetical, `cloudSizes` → `tc-1…tc-5`). - **Slugs**: auto-generated from the title; on collision generated slugs get `-2`, `-3`…, user-typed slugs return a 409 with a message. + `slug.Clean` is `Make` without the "untitled" fallback, for inputs that + should be rejected instead (tags). - **Errors**: `store.ErrNotFound` / `store.ErrConflict` are the sentinels (`store.wrap`). Handlers use `s.serverError` (logs, 500) and `s.plainError(status, msg)` (small standalone HTML page). @@ -297,7 +320,7 @@ superadmin password to `admin`. Production refuses both. `internal/db/migrations/control/` for users and the registry; goose `-- +goose Up/Down` sections; they run automatically at startup. Never edit an applied migration. Blog chain so far: `00001_init`, `00002_language`, - `00003_files`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after + `00003_files`, `00004_tags`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after the move to per-blog databases; deployments from before it have `goose_db_version` rows 2–7 in the control DB that must be deleted once (README "Upgrading from a single database") or the next control migration @@ -14,10 +14,13 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. the superadmin can set a different limit per blog. - **Announcements**: blog-wide notices (next meeting, this month's book, a closure) shown at the top or bottom of the main content or of a side column; each can be hidden without deleting. -- Every blog has **pages** (Home, About, News, …); each page holds posts. +- Every blog has **pages** (Home, About, News, …); each page holds posts. Posts can + carry **tags**, shown under the date; each tag has its own page listing every post + with it. - **Layout** tab: the page is five areas — header, left column, main content, right column, footer — each holding modules in any order: blog title, logo, menu, archive - (posts by year and month), recent posts, custom HTML, footer text, RSS link, site map. + (posts by year and month), recent posts, tags (with counts), tag cloud, custom HTML, + footer text, RSS link, site map. Column widths are percentages; on phones the columns stack under the posts. The **menu** mixes blog pages and outside links. - **Design** form: colour schemes, background colour/image, fonts, link style, content box, header, menu, post dates, footer, logo and site icon (favicon). diff --git a/cmd/blogspace/seed.go b/cmd/blogspace/seed.go index 05c54f8..14c378f 100644 --- a/cmd/blogspace/seed.go +++ b/cmd/blogspace/seed.go @@ -97,6 +97,8 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error { {Area: "footer", Kind: "text", Body: "© Alice — made with Blogspace"}, {Area: "right", Kind: "recent", Title: "Recent posts", Count: 5}, {Area: "right", Kind: "archive", Title: "Archive"}, + {Area: "right", Kind: "tags", Title: "Tags", Count: 10}, + {Area: "left", Kind: "tagcloud", Title: "Tag cloud"}, } { if _, err := bs.CreateModule(ctx, &m); err != nil { return err @@ -107,19 +109,34 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error { for i := 0; i < 10; i++ { body := fmt.Sprintf("Filler post number %d, here to demonstrate pagination.", i+1) post := &store.Post{PageID: home.ID, Title: fmt.Sprintf("Filler post %d", i+1), Slug: fmt.Sprintf("filler-%d", i+1), BodyMD: body, BodyHTML: markdown.Render(body), Published: true} - if _, err := bs.CreatePost(ctx, post); err != nil { + created, err := bs.CreatePost(ctx, post) + if err != nil { + return err + } + tags := []string{"filler"} // uneven use, so the tag cloud has some spread + if i%3 == 0 { + tags = append(tags, "Go") + } + if err := bs.SetPostTags(ctx, created.ID, seedTags(tags...)); err != nil { return err } } - posts := []struct{ page, title, body string }{ - {"home", "Welcome to my corner of the web", "This is the **first post**. It lives on the home page.\n\nThings I plan to write about:\n\n- tomatoes\n- Go\n- the occasional recipe\n\n + ")\n\nThat image above was uploaded through the dashboard, and here is a file to download: [notes.txt](/media/" + notes.ID.String() + ")."}, - {"home", "Why I still like plain HTML", "No frameworks, no build step. Just `<p>` tags and a stylesheet.\n\n> Simplicity is prerequisite for reliability. — Dijkstra\n\n```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```"}, - {"news", "Site is up", "The blog is live. Expect sporadic updates."}, - {"news", "Tomato season", "First ripe tomato of the year! Table for the record:\n\n| Variety | Days |\n|---|---|\n| Cherry | 62 |\n| Beefsteak | 85 |"}, + posts := []struct { + page, title, body string + tags []string + }{ + {"home", "Welcome to my corner of the web", "This is the **first post**. It lives on the home page.\n\nThings I plan to write about:\n\n- tomatoes\n- Go\n- the occasional recipe\n\n + ")\n\nThat image above was uploaded through the dashboard, and here is a file to download: [notes.txt](/media/" + notes.ID.String() + ").", []string{"Go", "tomatoes", "meta"}}, + {"home", "Why I still like plain HTML", "No frameworks, no build step. Just `<p>` tags and a stylesheet.\n\n> Simplicity is prerequisite for reliability. — Dijkstra\n\n```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```", []string{"Go", "web"}}, + {"news", "Site is up", "The blog is live. Expect sporadic updates.", []string{"meta"}}, + {"news", "Tomato season", "First ripe tomato of the year! Table for the record:\n\n| Variety | Days |\n|---|---|\n| Cherry | 62 |\n| Beefsteak | 85 |", []string{"tomatoes", "garden", "Συνταγές"}}, } for _, p := range posts { post := &store.Post{PageID: pages[p.page].ID, Title: p.title, Slug: slug.Make(p.title), BodyMD: p.body, BodyHTML: markdown.Render(p.body), Published: true} - if _, err := bs.CreatePost(ctx, post); err != nil { + created, err := bs.CreatePost(ctx, post) + if err != nil { + return err + } + if err := bs.SetPostTags(ctx, created.ID, seedTags(p.tags...)); err != nil { return err } } @@ -143,3 +160,12 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error { log.Printf("seed: superadmin %q (password %q in dev), blogger alice / alicealice at %s", cfg.SuperadminUsername, "admin", cfg.BlogURL("alice")) return nil } + +// seedTags builds tags the way the post form does: the slug is made from the name. +func seedTags(names ...string) []store.Tag { + out := make([]store.Tag, 0, len(names)) + for _, n := range names { + out = append(out, store.Tag{Name: n, Slug: slug.Make(n)}) + } + return out +} diff --git a/internal/db/migrations/blog/00004_tags.sql b/internal/db/migrations/blog/00004_tags.sql new file mode 100644 index 0000000..a2ac86a --- /dev/null +++ b/internal/db/migrations/blog/00004_tags.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Tags are shared by posts. The slug is both the URL (/tag/<slug>) and the +-- dedupe key, so "Go" and "go" are one tag (the first spelling is kept). +CREATE TABLE tags ( + id bigserial PRIMARY KEY, + name text NOT NULL, + slug text NOT NULL UNIQUE +); +CREATE TABLE post_tags ( + post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (post_id, tag_id) +); +CREATE INDEX post_tags_tag ON post_tags (tag_id); + +-- Two more layout modules: the tag list and the tag cloud. +ALTER TABLE modules DROP CONSTRAINT modules_kind_check; +ALTER TABLE modules ADD CONSTRAINT modules_kind_check + CHECK (kind IN ('title', 'logo', 'menu', 'archive', 'recent', 'html', 'rss', 'text', 'sitemap', 'tags', 'tagcloud')); + +-- +goose Down +DELETE FROM modules WHERE kind IN ('tags', 'tagcloud'); +ALTER TABLE modules DROP CONSTRAINT modules_kind_check; +ALTER TABLE modules ADD CONSTRAINT modules_kind_check + CHECK (kind IN ('title', 'logo', 'menu', 'archive', 'recent', 'html', 'rss', 'text', 'sitemap')); +DROP TABLE post_tags, tags; diff --git a/internal/i18n/el.go b/internal/i18n/el.go index ba29c83..0b63d38 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -93,6 +93,11 @@ var el = map[string]string{ "Enter the date as YYYY-MM-DD HH:MM.": "Γράψτε την ημερομηνία ως ΕΕΕΕ-ΜΜ-ΗΗ ΩΩ:ΛΛ.", "A post with that slug already exists on this page; choose another slug.": "Υπάρχει ήδη δημοσίευση με αυτή τη διεύθυνση στη σελίδα· διαλέξτε άλλη.", "Saved. Refresh your blog to see it.": "Αποθηκεύτηκε. Ανανεώστε το ιστολόγιό σας για να το δείτε.", + "New tags": "Νέες ετικέτες", + "comma-separated": "χωρισμένες με κόμμα", + "go, tomatoes, recipes": "go, ντομάτες, συνταγές", + "Tags on hidden posts are not shown on the blog; a tag nobody uses disappears.": "Οι ετικέτες κρυφών δημοσιεύσεων δεν εμφανίζονται στο ιστολόγιο· μια ετικέτα που δεν χρησιμοποιείται πουθενά εξαφανίζεται.", + "Tags: at most 20 per post, 40 characters each.": "Ετικέτες: έως 20 ανά δημοσίευση, 40 χαρακτήρες η καθεμία.", // ---- pages ---- "New page": "Νέα σελίδα", @@ -316,6 +321,8 @@ var el = map[string]string{ "Logo": "Λογότυπο", "Archive (posts by month)": "Αρχείο (δημοσιεύσεις ανά μήνα)", "Recent posts": "Πρόσφατες δημοσιεύσεις", + "Tags": "Ετικέτες", + "Tag cloud": "Σύννεφο ετικετών", "Custom HTML": "Δικό σας HTML", "RSS link": "Σύνδεσμος RSS", "Footer text": "Κείμενο υποσέλιδου", @@ -325,6 +332,8 @@ var el = map[string]string{ "Archive": "Αρχείο", "empty": "κενό", "heading “%s”": "επικεφαλίδα «%s»", + "%d tags": "%d ετικέτες", + "all tags": "όλες οι ετικέτες", // ---- module form ---- "in the %s": "— %s", @@ -332,7 +341,10 @@ var el = map[string]string{ "The newest published posts from every page of your blog, newest first.": "Οι νεότερες δημοσιεύσεις από όλες τις σελίδες του ιστολογίου, με τις πιο πρόσφατες πρώτα.", "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.": "Όλες οι δημοσιεύσεις ανά έτος και μήνα, με τις πιο πρόσφατες πρώτα. Έτη και μήνες ανοιγοκλείνουν· το τελευταίο έτος ξεκινά ανοιχτό.", "Links to every page of your blog.": "Σύνδεσμοι προς όλες τις σελίδες του ιστολογίου.", - "Hello!": "Γεια σας!", + "How many tags": "Πόσες ετικέτες", + "Your tags with the number of posts in each, most used first. Only published posts count.": "Οι ετικέτες σας με τον αριθμό δημοσιεύσεων της καθεμίας, οι πιο χρησιμοποιημένες πρώτα. Μετρούν μόνο οι ορατές δημοσιεύσεις.", + "Every tag in alphabetical order, bigger the more posts use it.": "Όλες οι ετικέτες αλφαβητικά, μεγαλύτερες όσο περισσότερες δημοσιεύσεις τις χρησιμοποιούν.", + "Hello!": "Γεια σας!", "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most.": "Μπαίνει στη σελίδα σας ακριβώς όπως γράφεται — ενσωματώσεις, σήματα και scripts δουλεύουν όλα. Ό,τι είναι λάθος εδώ (π.χ. μια ετικέτα που δεν κλείνει) μπορεί να χαλάσει τη σελίδα γύρω του, γι' αυτό ελέγξτε το ιστολόγιό σας μετά την αποθήκευση. Έως 20 KB.", "© 2026 Me": "© 2026 Εγώ", "Plain text; each line becomes its own paragraph.": "Απλό κείμενο· κάθε γραμμή γίνεται ξεχωριστή παράγραφος.", @@ -472,4 +484,6 @@ var el = map[string]string{ "There is nothing at this address.": "Δεν υπάρχει τίποτα σε αυτή τη διεύθυνση.", "Back to the front page.": "Πίσω στην αρχική σελίδα.", "RSS feed": "Ροή RSS", + "No tags yet.": "Δεν υπάρχουν ετικέτες ακόμα.", + "Tagged “%s”": "Με ετικέτα «%s»", } diff --git a/internal/slug/slug.go b/internal/slug/slug.go index a3c99a4..197160c 100644 --- a/internal/slug/slug.go +++ b/internal/slug/slug.go @@ -27,6 +27,15 @@ var greek = strings.NewReplacer( // Make returns a slug for s; falls back to "untitled" when nothing usable remains. func Make(s string) string { + if s = Clean(s); s == "" { + return "untitled" + } + return s +} + +// Clean is Make without the fallback: "" when nothing usable remains, so a +// caller can reject the input (a tag) rather than name it "untitled". +func Clean(s string) string { if t, _, err := transform.String(stripMarks, s); err == nil { s = t } @@ -36,9 +45,6 @@ func Make(s string) string { if len(s) > 80 { s = strings.Trim(s[:80], "-") } - if s == "" { - return "untitled" - } return s } diff --git a/internal/slug/slug_test.go b/internal/slug/slug_test.go index 9953f34..59e2a75 100644 --- a/internal/slug/slug_test.go +++ b/internal/slug/slug_test.go @@ -20,6 +20,15 @@ func TestMake(t *testing.T) { } } +func TestClean(t *testing.T) { + cases := map[string]string{"!!!": "", "": "", "Ντομάτες": "ntomates", "Go ": "go"} + for in, want := range cases { + if got := Clean(in); got != want { + t.Errorf("Clean(%q) = %q, want %q", in, got, want) + } + } +} + func TestValid(t *testing.T) { for _, ok := range []string{"a", "abc-def", "a1-b2"} { if !Valid(ok) { diff --git a/internal/store/pages.go b/internal/store/pages.go index 55cad33..3d5b02d 100644 --- a/internal/store/pages.go +++ b/internal/store/pages.go @@ -105,8 +105,10 @@ func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error { } func (bs *BlogStore) DeletePage(ctx context.Context, id int64) error { - _, err := bs.db.Exec(ctx, `DELETE FROM pages WHERE id=$1 AND NOT is_home`, id) - return err + if _, err := bs.db.Exec(ctx, `DELETE FROM pages WHERE id=$1 AND NOT is_home`, id); err != nil { + return err + } + return deleteOrphanTags(ctx, bs.db) // its posts went with it } // SetHomePage moves the home flag to the given page. diff --git a/internal/store/posts.go b/internal/store/posts.go index 569640b..706d132 100644 --- a/internal/store/posts.go +++ b/internal/store/posts.go @@ -18,16 +18,25 @@ type Post struct { // joined PageSlug string PageTitle string + Tags []Tag // by name; IDs are not loaded } -const postCols = `p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.published, p.created_at, p.updated_at, g.slug, g.title` +// The tags come along as two parallel arrays (names and slugs, both by name) +// so every post query stays a single round trip; the alias p is the posts row. +const postCols = `p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.published, p.created_at, p.updated_at, g.slug, g.title, + coalesce((SELECT array_agg(t.name ORDER BY t.name) FROM post_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.post_id=p.id), '{}'), + coalesce((SELECT array_agg(t.slug ORDER BY t.name) FROM post_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.post_id=p.id), '{}')` func scanPost(row interface{ Scan(...any) error }) (*Post, error) { var p Post - err := row.Scan(&p.ID, &p.PageID, &p.Slug, &p.Title, &p.BodyMD, &p.BodyHTML, &p.Published, &p.CreatedAt, &p.UpdatedAt, &p.PageSlug, &p.PageTitle) + var names, slugs []string + err := row.Scan(&p.ID, &p.PageID, &p.Slug, &p.Title, &p.BodyMD, &p.BodyHTML, &p.Published, &p.CreatedAt, &p.UpdatedAt, &p.PageSlug, &p.PageTitle, &names, &slugs) if err != nil { return nil, wrap(err) } + for i := range names { + p.Tags = append(p.Tags, Tag{Name: names[i], Slug: slugs[i]}) + } return &p, nil } @@ -130,6 +139,8 @@ func (bs *BlogStore) UpdatePost(ctx context.Context, p *Post) error { } func (bs *BlogStore) DeletePost(ctx context.Context, id int64) error { - _, err := bs.db.Exec(ctx, `DELETE FROM posts WHERE id=$1`, id) - return err + if _, err := bs.db.Exec(ctx, `DELETE FROM posts WHERE id=$1`, id); err != nil { + return err + } + return deleteOrphanTags(ctx, bs.db) } diff --git a/internal/store/tags.go b/internal/store/tags.go new file mode 100644 index 0000000..249a82e --- /dev/null +++ b/internal/store/tags.go @@ -0,0 +1,119 @@ +package store + +import "context" + +// Tag is a label shared by posts. The slug is the URL (/tag/<slug>) and the +// dedupe key: two spellings with the same slug are one tag. +type Tag struct { + ID int64 + Name string + Slug string +} + +// TagCount is a tag with the number of published posts that carry it. +type TagCount struct { + Name string + Slug string + Count int +} + +// ListTags returns every tag that is on at least one post, by name. +func (bs *BlogStore) ListTags(ctx context.Context) ([]Tag, error) { + rows, err := bs.db.Query(ctx, `SELECT t.id, t.name, t.slug FROM tags t + WHERE EXISTS (SELECT 1 FROM post_tags WHERE tag_id=t.id) ORDER BY t.name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Tag + for rows.Next() { + var t Tag + if err := rows.Scan(&t.ID, &t.Name, &t.Slug); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (bs *BlogStore) TagBySlug(ctx context.Context, slug string) (*Tag, error) { + var t Tag + err := bs.db.QueryRow(ctx, `SELECT id, name, slug FROM tags WHERE slug=$1`, slug).Scan(&t.ID, &t.Name, &t.Slug) + if err != nil { + return nil, wrap(err) + } + return &t, nil +} + +// TagCounts counts published posts per tag, by name. Hidden posts are left +// out so a tag used only on them never shows on the blog. +func (bs *BlogStore) TagCounts(ctx context.Context) ([]TagCount, error) { + rows, err := bs.db.Query(ctx, `SELECT t.name, t.slug, count(*) FROM tags t + JOIN post_tags pt ON pt.tag_id=t.id JOIN posts p ON p.id=pt.post_id + WHERE p.published GROUP BY t.id ORDER BY t.name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []TagCount + for rows.Next() { + var t TagCount + if err := rows.Scan(&t.Name, &t.Slug, &t.Count); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// PublishedPostsByTag returns a page of the published posts carrying the tag, from every page of the blog. +func (bs *BlogStore) PublishedPostsByTag(ctx context.Context, tagID int64, limit, offset int) ([]Post, int, error) { + posts, err := bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + JOIN post_tags pt ON pt.post_id=p.id + WHERE pt.tag_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3`, tagID, limit, offset) + if err != nil { + return nil, 0, err + } + var total int + err = bs.db.QueryRow(ctx, `SELECT count(*) FROM posts p JOIN post_tags pt ON pt.post_id=p.id WHERE pt.tag_id=$1 AND p.published`, tagID).Scan(&total) + return posts, total, err +} + +// SetPostTags makes the given tags the post's tags: new ones are created, +// dropped ones unlinked, and tags no post uses any more are deleted. +func (bs *BlogStore) SetPostTags(ctx context.Context, postID int64, tags []Tag) error { + tx, err := bs.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + ids := make([]int64, 0, len(tags)) + for _, t := range tags { + var id int64 + // The no-op update makes RETURNING fire on conflict; the stored spelling stays. + if err := tx.QueryRow(ctx, `INSERT INTO tags (name, slug) VALUES ($1,$2) + ON CONFLICT (slug) DO UPDATE SET name=tags.name RETURNING id`, t.Name, t.Slug).Scan(&id); err != nil { + return err + } + ids = append(ids, id) + } + if _, err := tx.Exec(ctx, `DELETE FROM post_tags WHERE post_id=$1 AND tag_id <> ALL($2)`, postID, ids); err != nil { + return err + } + for _, id := range ids { + if _, err := tx.Exec(ctx, `INSERT INTO post_tags (post_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, postID, id); err != nil { + return err + } + } + if err := deleteOrphanTags(ctx, tx); err != nil { + return err + } + return tx.Commit(ctx) +} + +// deleteOrphanTags drops tags no post carries any more, so the post form's +// list only offers tags that are in use. +func deleteOrphanTags(ctx context.Context, q querier) error { + _, err := q.Exec(ctx, `DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM post_tags WHERE tag_id=t.id)`) + return err +} diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index f2e7713..c6030f2 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -51,10 +51,26 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) { } v["recent"] = recent } + if layout.NeedsTags() { + counts, err := blogStore(r).TagCounts(ctx) + if err != nil { + return nil, err + } + v["tags"], v["tagcloud"] = topTags(counts), cloudSizes(counts) + } v["notices"] = placeNotices(sections, layout) return v, nil } +// pageNum reads the ?p= of a paginated listing (1 when absent or silly). +func pageNum(r *http.Request) int { + n, _ := strconv.Atoi(r.URL.Query().Get("p")) + if n < 1 { + n = 1 + } + return n +} + func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { page, err := blogStore(r).HomePage(r.Context()) if err != nil { @@ -83,10 +99,7 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store. s.serverError(w, err) return } - n, _ := strconv.Atoi(r.URL.Query().Get("p")) - if n < 1 { - n = 1 - } + n := pageNum(r) posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage) if err != nil { s.serverError(w, err) @@ -94,13 +107,42 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store. } last := (total + postsPerPage - 1) / postsPerPage v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last + // The pager appends ?p=N to base: no trailing slash, which /{page} would not match. v["base"] = "/" + page.Slug if page.IsHome { - v["base"] = "" + v["base"] = "/" } s.render(w, r, "blog/page.html", v) } +// handleBlogTag lists the published posts carrying a tag, from every page of +// the blog. A tag that is only on hidden posts is as good as absent. +func (s *Server) handleBlogTag(w http.ResponseWriter, r *http.Request) { + tag, err := blogStore(r).TagBySlug(r.Context(), r.PathValue("tag")) + if err != nil { + s.blogNotFound(w, r) + return + } + n := pageNum(r) + posts, total, err := blogStore(r).PublishedPostsByTag(r.Context(), tag.ID, postsPerPage, (n-1)*postsPerPage) + if err != nil { + s.serverError(w, err) + return + } + if total == 0 { + s.blogNotFound(w, r) + return + } + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + last := (total + postsPerPage - 1) / postsPerPage + v["tag"], v["posts"], v["pageNum"], v["lastPage"], v["base"] = tag, posts, n, last, "/tag/"+tag.Slug + s.render(w, r, "blog/tag.html", v) +} + func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) { page, err := blogStore(r).PageBySlug(r.Context(), r.PathValue("page")) if err != nil { diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go index 94bf32f..64d8ff4 100644 --- a/internal/web/handlers_layout.go +++ b/internal/web/handlers_layout.go @@ -121,6 +121,11 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { m.Title = i18n.T(blogLang, "Recent posts") case "sitemap": m.Title = i18n.T(blogLang, "Pages") + case "tags": + m.Title = i18n.T(blogLang, "Tags") + m.Count = 10 + case "tagcloud": + m.Title = i18n.T(blogLang, "Tag cloud") } m, err := blogStore(r).CreateModule(r.Context(), m) if err != nil { @@ -169,7 +174,7 @@ func (s *Server) handleModuleForm(w http.ResponseWriter, r *http.Request) { } func (s *Server) moduleFormData(r *http.Request, m *store.Module, errMsg string) map[string]any { - d := map[string]any{"module": m, "name": s.tr(r, moduleNames[m.Kind]), "area": s.tr(r, areaNames[m.Area]), "counts": []int{3, 5, 8, 10, 15, 20}} + d := map[string]any{"module": m, "name": s.tr(r, moduleNames[m.Kind]), "area": s.tr(r, areaNames[m.Area]), "counts": []int{3, 5, 8, 10, 15, 20}, "tagCounts": []int{5, 10, 20, 30, 50}} if errMsg != "" { d["error"] = errMsg } @@ -184,7 +189,11 @@ func (s *Server) handleModuleSave(w http.ResponseWriter, r *http.Request) { m.Title = strings.TrimSpace(r.FormValue("title")) m.Body = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") if n, err := strconv.Atoi(r.FormValue("count")); err == nil { - m.Count = clampInt(n, 1, maxRecentCount) + if m.Kind == "tags" { // 0 = all of them + m.Count = clampInt(n, 0, maxTagListCount) + } else { + m.Count = clampInt(n, 1, maxRecentCount) + } } fail := func(msg string) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", s.moduleFormData(r, m, msg)) diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index 3b128e0..bc74f71 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -13,7 +13,7 @@ import ( // Page slugs that would collide with blog routes, or with management routes on the root domain. var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true, "favicon.ico": true, - "webadmin": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true} + "webadmin": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true, "tag": true} func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { pages, err := blogStore(r).ListPages(r.Context()) diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index d38c5f2..16d9cf2 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -54,6 +54,11 @@ func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } + allTags, err := blogStore(r).ListTags(r.Context()) + if err != nil { + s.serverError(w, err) + return + } p := &store.Post{Published: true, CreatedAt: time.Now()} if r.PathValue("id") != "" { if p = s.loadPost(w, r); p == nil { @@ -64,7 +69,7 @@ func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { } else if hp, err := blogStore(r).HomePage(r.Context()); err == nil { p.PageID = hp.ID } - s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages}) + s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "allTags": allTags, "tagged": tagSet(p.Tags), "newTags": ""}) } func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { @@ -74,6 +79,11 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } + allTags, err := blogStore(r).ListTags(r.Context()) + if err != nil { + s.serverError(w, err) + return + } p := &store.Post{CreatedAt: time.Now()} if r.PathValue("id") != "" { if p = s.loadPost(w, r); p == nil { @@ -85,12 +95,16 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { p.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") p.Published = r.FormValue("published") == "on" p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) + // The ticked existing tags and the comma-separated new ones are one list. + tags, tagsOK := parseTags(append(r.Form["tag"], strings.Split(r.FormValue("new_tags"), ",")...)) + p.Tags = tags autoSlug := p.Slug == "" if autoSlug { p.Slug = slug.Make(p.Title) } fail := func(status int, msg string) { - s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg}) + s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg, + "allTags": allTags, "tagged": tagSet(p.Tags), "newTags": r.FormValue("new_tags")}) } if at := strings.TrimSpace(r.FormValue("posted_at")); at != "" { // blank keeps the current date var ok bool @@ -124,6 +138,9 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { case len(p.BodyMD) > 200_000: fail(http.StatusBadRequest, s.tr(r, "Post is too long (200 KB max).")) return + case !tagsOK: + fail(http.StatusBadRequest, s.tr(r, "Tags: at most 20 per post, 40 characters each.")) + return } p.BodyHTML = markdown.Render(p.BodyMD) base := p.Slug @@ -149,6 +166,10 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } + if err := blogStore(r).SetPostTags(r.Context(), p.ID, tags); err != nil { + s.serverError(w, err) + return + } redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it.")) } diff --git a/internal/web/layout.go b/internal/web/layout.go index ad55e82..b8069a0 100644 --- a/internal/web/layout.go +++ b/internal/web/layout.go @@ -13,8 +13,8 @@ import ( // blocks; the side columns take the small boxes; the footer its own set. var moduleKinds = map[string][]string{ "header": {"title", "logo", "menu"}, - "left": {"menu", "recent", "archive", "logo", "html"}, - "right": {"menu", "recent", "archive", "logo", "html"}, + "left": {"menu", "recent", "archive", "tags", "tagcloud", "logo", "html"}, + "right": {"menu", "recent", "archive", "tags", "tagcloud", "logo", "html"}, "above": {"html"}, "below": {"html"}, "footer": {"text", "rss", "sitemap", "html"}, @@ -22,15 +22,17 @@ var moduleKinds = map[string][]string{ // Friendly names for the dashboard (English; translate with t / s.tr at the point of use). var moduleNames = map[string]string{ - "title": "Blog title & tagline", - "logo": "Logo", - "menu": "Menu", - "archive": "Archive (posts by month)", - "recent": "Recent posts", - "html": "Custom HTML", - "rss": "RSS link", - "text": "Footer text", - "sitemap": "Site map", + "title": "Blog title & tagline", + "logo": "Logo", + "menu": "Menu", + "archive": "Archive (posts by month)", + "recent": "Recent posts", + "tags": "Tags", + "tagcloud": "Tag cloud", + "html": "Custom HTML", + "rss": "RSS link", + "text": "Footer text", + "sitemap": "Site map", } var areaNames = map[string]string{ @@ -53,7 +55,7 @@ func allowedKind(area, kind string) bool { // moduleHasSettings says whether the module has an edit page. func moduleHasSettings(kind string) bool { switch kind { - case "html", "text", "recent", "archive", "sitemap": + case "html", "text", "recent", "archive", "sitemap", "tags", "tagcloud": return true } return false @@ -64,6 +66,11 @@ func moduleSummary(lang string, m store.Module) string { switch m.Kind { case "recent": return i18n.Tf(lang, "%d posts", m.Count) + headingNote(lang, m) + case "tags": + if m.Count == 0 { + return i18n.T(lang, "all tags") + headingNote(lang, m) + } + return i18n.Tf(lang, "%d tags", m.Count) + headingNote(lang, m) case "html", "text": body := strings.Join(strings.Fields(m.Body), " ") if body == "" { @@ -160,6 +167,16 @@ func (l Layout) NeedsArchive() bool { return false } +// NeedsTags says whether any visible module shows the tags. +func (l Layout) NeedsTags() bool { + for _, m := range l.all() { + if m.Kind == "tags" || m.Kind == "tagcloud" { + return true + } + } + return false +} + // MaxRecent is the longest "recent posts" list any visible module wants (0 = none). func (l Layout) MaxRecent() int { n := 0 diff --git a/internal/web/routes.go b/internal/web/routes.go index 26264ef..a8a60d2 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -95,6 +95,7 @@ func (s *Server) rootRoutes() http.Handler { func (s *Server) blogRoutes(m *http.ServeMux, wrap func(http.HandlerFunc) http.HandlerFunc) { m.HandleFunc("GET /{$}", wrap(s.handleBlogHome)) m.HandleFunc("GET /feed.xml", wrap(s.handleBlogFeed)) + m.HandleFunc("GET /tag/{tag}", wrap(s.handleBlogTag)) // literal segment: wins over /{page}/{post} m.HandleFunc("GET /{page}", wrap(s.handleBlogPage)) m.HandleFunc("GET /{page}/{post}", wrap(s.handleBlogPost)) } diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css index d4465a2..e7a6c96 100644 --- a/internal/web/static/blog.css +++ b/internal/web/static/blog.css @@ -71,6 +71,15 @@ img { max-width: 100%; height: auto; } .col .site-nav .mod-title { padding: 0 1em; } .col .site-nav .nav-inner { padding: 0; } .col .site-nav a { display: block; padding: 0.4em 1em; } +.post-tags { margin: -0.5em 0 0.8em; font-size: 0.85em; opacity: 0.8; } +.post-tags a { margin-right: 0.7em; } +.tagcloud { margin: 0; line-height: 1.8; } +.tagcloud a { display: inline-block; margin-right: 0.5em; text-decoration: none; } +.tc-1 { font-size: 0.85em; opacity: 0.75; } +.tc-2 { font-size: 1em; } +.tc-3 { font-size: 1.2em; } +.tc-4 { font-size: 1.45em; } +.tc-5 { font-size: 1.75em; } .mod-archive details { margin: 0.2em 0; } .mod-archive details details { margin-left: 1em; } .mod-archive summary { cursor: pointer; padding: 0.15em 0; } diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index b85d515..36de479 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -85,6 +85,10 @@ label.check { font-weight: normal; font-size: 0.95em; } label.check input { width: auto; margin: 0 0.45em 0 0; vertical-align: middle; accent-color: var(--accent); } input[type=radio], input[type=checkbox] { accent-color: var(--accent); } .row { display: flex; flex-wrap: wrap; gap: 0 1.5em; } +/* The post form's tag checklist: a label-like heading over a wrapped row of checkboxes. */ +p.lbl { margin: 0.9em 0 0.3em; font-size: 0.875em; font-weight: 500; } +.tagpick { display: flex; flex-wrap: wrap; gap: 0.2em 1.2em; margin: 0 0 0.3em; } +.tagpick label.check { margin: 0; } .row label { flex: 1 1 12em; min-width: 12em; } button, .btn { display: inline-block; font: inherit; font-size: 0.9em; font-weight: 600; padding: 0.5em 1.05em; border: 1px solid var(--accent); border-radius: var(--radius-sm); background: var(--accent); color: #fff; cursor: pointer; text-decoration: none; vertical-align: middle; line-height: 1.4; } button:hover, .btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } diff --git a/internal/web/tags.go b/internal/web/tags.go new file mode 100644 index 0000000..ba49260 --- /dev/null +++ b/internal/web/tags.go @@ -0,0 +1,88 @@ +package web + +import ( + "sort" + "strings" + "unicode/utf8" + + "github.com/gramanas/blogspace/internal/slug" + "github.com/gramanas/blogspace/internal/store" +) + +const ( + maxTagsPerPost = 20 + maxTagLen = 40 // runes: Greek names are two bytes a letter + maxTagListCount = 50 // the Tags module's "how many"; 0 = all +) + +// parseTags normalises what the post form sends (the ticked tags and the +// comma-separated new ones): whitespace trimmed and collapsed, blanks and +// names with nothing slug-worthy dropped, duplicates removed by slug with the +// first spelling kept. ok is false when a name is too long or there are too many. +func parseTags(raw []string) (tags []store.Tag, ok bool) { + seen := map[string]bool{} + for _, r := range raw { + name := strings.Join(strings.Fields(r), " ") + if name == "" { + continue + } + if utf8.RuneCountInString(name) > maxTagLen { + return nil, false + } + sl := slug.Clean(name) + if sl == "" || seen[sl] { + continue + } + seen[sl] = true + tags = append(tags, store.Tag{Name: name, Slug: sl}) + } + return tags, len(tags) <= maxTagsPerPost +} + +// tagSet is the post's tags by slug, for ticking the form's checklist. +func tagSet(tags []store.Tag) map[string]bool { + out := map[string]bool{} + for _, t := range tags { + out[t.Slug] = true + } + return out +} + +// topTags orders tags by use, then name: the Tags module's list. +func topTags(tags []store.TagCount) []store.TagCount { + out := append([]store.TagCount(nil), tags...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Name < out[j].Name + }) + return out +} + +// cloudTag is a tag with its font-size step for the tag cloud (class tc-1 … tc-5). +type cloudTag struct { + store.TagCount + Size int +} + +// cloudSizes spreads the counts linearly over five sizes between the least and +// most used tag; when every tag is used equally they all get the middle size. +func cloudSizes(tags []store.TagCount) []cloudTag { + out := make([]cloudTag, 0, len(tags)) + if len(tags) == 0 { + return out + } + lo, hi := tags[0].Count, tags[0].Count + for _, t := range tags { + lo, hi = min(lo, t.Count), max(hi, t.Count) + } + for _, t := range tags { + size := 3 + if hi > lo { + size = 1 + (t.Count-lo)*4/(hi-lo) + } + out = append(out, cloudTag{t, size}) + } + return out +} diff --git a/internal/web/templates/blog/page.html b/internal/web/templates/blog/page.html index ac6a609..99403ef 100644 --- a/internal/web/templates/blog/page.html +++ b/internal/web/templates/blog/page.html @@ -2,18 +2,5 @@ {{define "content"}} {{if not .Data.page.IsHome}}<h1 class="page-title">{{.Data.page.Title}}</h1>{{end}} {{if .Data.page.IntroHTML}}<div class="intro">{{html .Data.page.IntroHTML}}</div>{{end}} -{{range .Data.posts}} -<article class="post"> - <h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2> - {{if $.Data.theme.ShowDates}}<p class="post-date">{{postdate $.Data.theme.DateFormat .CreatedAt}}</p>{{end}} - <div class="post-body">{{html .BodyHTML}}</div> -</article> -{{else}}{{if not .Data.page.IntroHTML}}<p class="muted">{{t "Nothing here yet."}}</p>{{end}}{{end}} -{{if gt .Data.lastPage 1}} -<div class="pager"> - {{if gt .Data.pageNum 1}}<a href="{{.Data.base}}/?p={{sub .Data.pageNum 1}}">← {{t "Newer"}}</a>{{end}} - <span>{{tf "Page %d of %d" .Data.pageNum .Data.lastPage}}</span> - {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.base}}/?p={{add .Data.pageNum 1}}">{{t "Older"}} →</a>{{end}} -</div> -{{end}} +{{if or .Data.posts .Data.page.IntroHTML}}{{template "postlist" .}}{{else}}<p class="muted">{{t "Nothing here yet."}}</p>{{end}} {{end}} diff --git a/internal/web/templates/blog/post.html b/internal/web/templates/blog/post.html index d14f22a..a3b0375 100644 --- a/internal/web/templates/blog/post.html +++ b/internal/web/templates/blog/post.html @@ -3,6 +3,7 @@ <article class="post single"> <h1 class="post-title">{{.Data.post.Title}}</h1> <p class="post-date">{{if .Data.theme.ShowDates}}{{postdate .Data.theme.DateFormat .Data.post.CreatedAt}} · {{end}}<a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p> + {{template "posttags" .Data.post.Tags}} <div class="post-body">{{html .Data.post.BodyHTML}}</div> </article> {{end}} diff --git a/internal/web/templates/blog/tag.html b/internal/web/templates/blog/tag.html new file mode 100644 index 0000000..fd3aa13 --- /dev/null +++ b/internal/web/templates/blog/tag.html @@ -0,0 +1,5 @@ +{{define "title"}}{{tf "Tagged “%s”" .Data.tag.Name}} · {{.Blog.Title}}{{end}} +{{define "content"}} +<h1 class="page-title">{{tf "Tagged “%s”" .Data.tag.Name}}</h1> +{{template "postlist" .}} +{{end}} diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html index 0cd27fa..8b28f2e 100644 --- a/internal/web/templates/dashboard/module_form.html +++ b/internal/web/templates/dashboard/module_form.html @@ -5,8 +5,8 @@ <h1>{{.Data.name}} <span class="muted small">{{tf "in the %s" (lower .Data.area)}}</span></h1> <form method="post"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - {{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} - <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}{{t "Recent posts"}}{{else if eq $m.Kind "archive"}}{{t "Archive"}}{{else if eq $m.Kind "sitemap"}}{{t "Pages"}}{{end}}"></label> + {{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (eq $m.Kind "tags") (eq $m.Kind "tagcloud") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} + <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}{{t "Recent posts"}}{{else if eq $m.Kind "archive"}}{{t "Archive"}}{{else if eq $m.Kind "sitemap"}}{{t "Pages"}}{{else if eq $m.Kind "tags"}}{{t "Tags"}}{{else if eq $m.Kind "tagcloud"}}{{t "Tag cloud"}}{{end}}"></label> {{end}} {{if eq $m.Kind "recent"}} <div class="row"><label>{{t "How many posts"}}<br><select name="count">{{range $n := .Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> @@ -15,6 +15,11 @@ <p class="muted small">{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}</p> {{else if eq $m.Kind "sitemap"}} <p class="muted small">{{t "Links to every page of your blog."}}</p> + {{else if eq $m.Kind "tags"}} + <div class="row"><label>{{t "How many tags"}}<br><select name="count"><option value="0"{{if eq $m.Count 0}} selected{{end}}>{{t "All"}}</option>{{range $n := .Data.tagCounts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> + <p class="muted small">{{t "Your tags with the number of posts in each, most used first. Only published posts count."}}</p> + {{else if eq $m.Kind "tagcloud"}} + <p class="muted small">{{t "Every tag in alphabetical order, bigger the more posts use it."}}</p> {{else if eq $m.Kind "html"}} <label for="mod-body">HTML</label> <textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="<p>{{t "Hello!"}}</p>">{{$m.Body}}</textarea> diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html index 9f188f7..fd47615 100644 --- a/internal/web/templates/dashboard/post_form.html +++ b/internal/web/templates/dashboard/post_form.html @@ -14,6 +14,11 @@ <label>{{t "Date"}}<br> <input type="datetime-local" name="posted_at" value="{{.Data.post.CreatedAt.Format "2006-01-02T15:04"}}" placeholder="YYYY-MM-DD HH:MM"></label> </div> + {{if .Data.allTags}}<p class="lbl">{{t "Tags"}}</p> + <div class="tagpick">{{range .Data.allTags}}<label class="check"><input type="checkbox" name="tag" value="{{.Name}}"{{if index $.Data.tagged .Slug}} checked{{end}}> {{.Name}}</label>{{end}}</div>{{end}} + <label>{{if .Data.allTags}}{{t "New tags"}}{{else}}{{t "Tags"}}{{end}} <span class="muted">({{t "comma-separated"}})</span><br> + <input name="new_tags" value="{{.Data.newTags}}" maxlength="900" placeholder="{{t "go, tomatoes, recipes"}}"></label> + <p class="muted small">{{t "Tags on hidden posts are not shown on the blog; a tag nobody uses disappears."}}</p> <label for="ed-body">{{t "Content"}} <span class="muted">(Markdown)</span></label> {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label> diff --git a/internal/web/templates/dashboard/posts.html b/internal/web/templates/dashboard/posts.html index 3a99a31..983fbae 100644 --- a/internal/web/templates/dashboard/posts.html +++ b/internal/web/templates/dashboard/posts.html @@ -17,7 +17,7 @@ <table> <tr><th>{{t "Title"}}</th><th>{{t "Page"}}</th><th>{{t "Date"}}</th><th></th></tr> {{range .Data.posts}}<tr> - <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">{{t "hidden"}}</span>{{end}}</td> + <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">{{t "hidden"}}</span>{{end}}{{if .Tags}}<div class="muted small">{{range $i, $t := .Tags}}{{if $i}}, {{end}}{{$t.Name}}{{end}}</div>{{end}}</td> <td>{{.PageTitle}}</td> <td class="nowrap">{{date .CreatedAt}}</td> <td class="nowrap"><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">{{t "view"}} ↗</a> · <a class="danger" href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/delete">{{t "delete"}}</a></td> diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index f73f729..cf00272 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -70,6 +70,12 @@ </details>{{end}} </details>{{else}}<p class="muted">{{t "No posts yet."}}</p>{{end}} </div> +{{- else if eq $m.Kind "tags"}}<div class="mod mod-tags">{{template "modtitle" $m}} + <ul>{{range $i, $t := $r.Data.tags}}{{if or (eq $m.Count 0) (lt $i $m.Count)}}<li><a href="/tag/{{$t.Slug}}">{{$t.Name}}</a> <span class="muted">({{$t.Count}})</span></li>{{end}}{{else}}<li class="muted">{{t "No tags yet."}}</li>{{end}}</ul> +</div> +{{- else if eq $m.Kind "tagcloud"}}<div class="mod mod-tagcloud">{{template "modtitle" $m}} + <p class="tagcloud">{{range $r.Data.tagcloud}}<a class="tc-{{.Size}}" href="/tag/{{.Slug}}" title="{{.Count}}">{{.Name}}</a> {{else}}<span class="muted">{{t "No tags yet."}}</span>{{end}}</p> +</div> {{- else if eq $m.Kind "html"}}{{if not $m.Body}}{{else if or (eq $m.Area "above") (eq $m.Area "below")}}<div class="custom-html custom-html-{{$m.Area}}">{{html $m.Body}}</div> {{else}}<div class="mod mod-html">{{template "modtitle" $m}}{{html $m.Body}}</div>{{end}} {{- else if eq $m.Kind "rss"}}<p class="mod mod-rss small"><a href="/feed.xml">{{t "RSS feed"}}</a></p> diff --git a/internal/web/templates/partials/postlist.html b/internal/web/templates/partials/postlist.html new file mode 100644 index 0000000..01e1037 --- /dev/null +++ b/internal/web/templates/partials/postlist.html @@ -0,0 +1,22 @@ +{{/* The article list and pager shared by a page and a tag listing. Expects + .Data.posts, .Data.pageNum, .Data.lastPage and .Data.base (the listing's + own path, which ?p=N is appended to). */}} +{{define "postlist"}} +{{range .Data.posts}} +<article class="post"> + <h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2> + {{if $.Data.theme.ShowDates}}<p class="post-date">{{postdate $.Data.theme.DateFormat .CreatedAt}}</p>{{end}} + {{template "posttags" .Tags}} + <div class="post-body">{{html .BodyHTML}}</div> +</article> +{{end}} +{{if gt .Data.lastPage 1}} +<div class="pager"> + {{if gt .Data.pageNum 1}}<a href="{{.Data.base}}?p={{sub .Data.pageNum 1}}">← {{t "Newer"}}</a>{{end}} + <span>{{tf "Page %d of %d" .Data.pageNum .Data.lastPage}}</span> + {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.base}}?p={{add .Data.pageNum 1}}">{{t "Older"}} →</a>{{end}} +</div> +{{end}} +{{end}} + +{{define "posttags"}}{{if .}}<p class="post-tags">{{range .}}<a href="/tag/{{.Slug}}">{{.Name}}</a> {{end}}</p>{{end}}{{end}} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 9520953..5ab8219 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "testing" "time" @@ -73,7 +74,7 @@ func TestRootRoutePrecedence(t *testing.T) { t.Errorf("%s: got %d, want %d", path, rec.Code, want) } } - for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml", "favicon.ico"} { + for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml", "favicon.ico", "tag"} { if !reservedPageSlugs[slug] { t.Errorf("page slug %q should be reserved", slug) } @@ -146,15 +147,15 @@ func TestLayoutTheme(t *testing.T) { func TestBuildLayout(t *testing.T) { mods := []store.Module{{ID: 1, Area: "header", Kind: "title"}, {ID: 2, Area: "left", Kind: "recent", Count: 3}, {ID: 3, Area: "right", Kind: "recent", Count: 8}, - {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}} + {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}, {ID: 7, Area: "right", Kind: "tagcloud"}} th := DefaultTheme() l := buildLayout(th, mods) - if !l.HasLeft || !l.HasRight || len(l.Header) != 1 || len(l.Footer) != 1 || len(l.Above) != 1 || l.MaxRecent() != 8 || !l.NeedsArchive() { + if !l.HasLeft || !l.HasRight || len(l.Header) != 1 || len(l.Footer) != 1 || len(l.Above) != 1 || l.MaxRecent() != 8 || !l.NeedsArchive() || !l.NeedsTags() { t.Errorf("layout: %+v", l) } th.RightOn, th.FooterOn = false, false l = buildLayout(th, mods) - if l.HasRight || len(l.Footer) != 0 || l.MaxRecent() != 3 || l.NeedsArchive() { + if l.HasRight || len(l.Footer) != 0 || l.MaxRecent() != 3 || l.NeedsArchive() || l.NeedsTags() { t.Errorf("switched-off areas should drop their modules: %+v", l) } if buildLayout(th, nil).HasLeft { @@ -206,6 +207,61 @@ func TestModuleKinds(t *testing.T) { if got := moduleSummary("en", store.Module{Kind: "menu"}); got != "" { t.Errorf("menu summary: %q", got) } + if got := moduleSummary("en", store.Module{Kind: "tags", Count: 8}); got != "8 tags" { + t.Errorf("tags summary: %q", got) + } + if got := moduleSummary("en", store.Module{Kind: "tags"}); got != "all tags" { + t.Errorf("all-tags summary: %q", got) + } +} + +func TestParseTags(t *testing.T) { + tags, ok := parseTags([]string{" Go ", "go", "GO", "", "tomatoes plants", "!!!", "Ντομάτες"}) + if !ok || len(tags) != 3 { + t.Fatalf("parseTags: ok=%v %+v", ok, tags) + } + for i, want := range []store.Tag{{Name: "Go", Slug: "go"}, {Name: "tomatoes plants", Slug: "tomatoes-plants"}, {Name: "Ντομάτες", Slug: "ntomates"}} { + if tags[i] != want { + t.Errorf("tag %d = %+v, want %+v", i, tags[i], want) + } + } + if tags, ok := parseTags(nil); !ok || len(tags) != 0 { + t.Errorf("no tags: ok=%v %+v", ok, tags) + } + if _, ok := parseTags([]string{strings.Repeat("α", 41)}); ok { + t.Error("41 runes should be too long") + } + if _, ok := parseTags([]string{strings.Repeat("α", 40)}); !ok { + t.Error("40 runes are fine") + } + many := make([]string, 21) + for i := range many { + many[i] = "t" + strconv.Itoa(i) + } + if _, ok := parseTags(many); ok { + t.Error("21 tags should be too many") + } + if !tagSet(tags)["ntomates"] || tagSet(tags)["nope"] { + t.Error("tagSet") + } +} + +func TestCloudSizes(t *testing.T) { + if got := cloudSizes(nil); len(got) != 0 { + t.Errorf("empty: %+v", got) + } + same := cloudSizes([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 1}}) + if same[0].Size != 3 || same[1].Size != 3 { + t.Errorf("equal counts get the middle size: %+v", same) + } + spread := cloudSizes([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 5}, {Name: "c", Count: 9}}) + if spread[0].Size != 1 || spread[1].Size != 3 || spread[2].Size != 5 || spread[0].Name != "a" { + t.Errorf("spread: %+v", spread) + } + top := topTags([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 5}, {Name: "c", Count: 5}}) + if top[0].Name != "b" || top[1].Name != "c" || top[2].Name != "a" { + t.Errorf("topTags: %+v", top) + } } func TestValidLinkURL(t *testing.T) { @@ -323,7 +379,7 @@ func TestAllTemplatesParse(t *testing.T) { for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html", "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", - "blog/page.html", "blog/post.html", "blog/404.html"} { + "blog/page.html", "blog/post.html", "blog/tag.html", "blog/404.html"} { for _, l := range i18n.Languages() { if _, err := tpl.get(l.Code, name); err != nil { t.Errorf("%s (%s): %v", name, l.Code, err) |
