aboutsummaryrefslogtreecommitdiffstats
path: root/AGENTS.md
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-15 23:12:51 +0300
committergrm <grm@eyesin.space>2026-09-15 23:12:51 +0300
commit2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff (patch)
treeecd5efd574b7d7463e4b2c4361c1994c4f28f561 /AGENTS.md
parent55732bdfc25933bab26220ccd82fe5e172d9e673 (diff)
downloadblogspace-2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff.tar.gz
blogspace-2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff.tar.bz2
blogspace-2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff.zip
Add post tags, with a tag page and two side-column modules
Posts can now carry tags, set on the post form as a checklist of the blog's existing tags plus a comma-separated box for new ones (no JS). A tag is a name and a unique slug, so "Go" and "go" are one tag and Greek tags get readable URLs; tags no post uses any more are deleted. On the blog, tags appear under the post date and link to /tag/<slug>, which lists the published posts from every page, paginated like a page. Two new layout modules show them: a Tags list (with counts, by use) and a Tag cloud (alphabetical, sized by use). Both are only fetched when a visible module needs them. The article loop and pager move to a shared postlist partial; while there, the pager stops adding a trailing slash — /news/?p=2 was a 404 because a {page} wildcard never matches one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'AGENTS.md')
-rw-r--r--AGENTS.md39
1 files changed, 31 insertions, 8 deletions
diff --git a/AGENTS.md b/AGENTS.md
index c716515..974fc10 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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