diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index b5619ab..c99f07f 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -48,6 +48,9 @@ type UseCases struct { GetArticleUseCase *useCases.GetArticleUseCase ListArticlesUseCase *useCases.ListArticlesUseCase UpdateArticleUseCase *useCases.UpdateArticleUseCase + ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase + CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase + ListSitemapUseCase *useCases.ListSitemapUseCase DeleteArticleUseCase *useCases.DeleteArticleUseCase ListAuthorsUseCase *useCases.ListAuthorsUseCase CreateAuthorUseCase *useCases.CreateAuthorUseCase @@ -328,6 +331,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder), + ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo), + CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo), + ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo), ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo), CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder), CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder), diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index 8ff1758..042cae1 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -16,6 +16,7 @@ import ( "log" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -79,8 +80,10 @@ type articleResponse struct { type listItemResponse struct { baseArticleResponse - Excerpt string `json:"excerpt"` - Slug string `json:"slug"` + Excerpt string `json:"excerpt"` + Slug string `json:"slug"` + SEOStatus string `json:"seo_status"` + SEOSuggestions int `json:"seo_suggestions"` } func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArticleResponse, error) { @@ -135,12 +138,12 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl }, nil } -func toListItemResponse(article domain.Article, format domain.BodyFormat) (listItemResponse, error) { +func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat) (listItemResponse, error) { base, err := toBaseResponse(article, format) if err != nil { return listItemResponse{}, err } - return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug}, nil + return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil } func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) { @@ -392,6 +395,22 @@ func hasDuplicates(ids []uint32) bool { return false } +func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) { + known := 0 + for name, value := range fields { + switch { + case slices.Contains(nonNullable, name): + if string(value) == "null" { + return 0, false + } + known++ + case slices.Contains(nullableArticleFields, name): + known++ + } + } + return known, true +} + func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) { raw, err := io.ReadAll(r.Body) if err != nil { @@ -401,23 +420,8 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) { if err := json.Unmarshal(raw, &fields); err != nil { return UpdateArticleBody{}, false } - known := 0 - for _, name := range nonNullableArticleFields { - value, present := fields[name] - if !present { - continue - } - if string(value) == "null" { - return UpdateArticleBody{}, false - } - known++ - } - for _, name := range nullableArticleFields { - if _, present := fields[name]; present { - known++ - } - } - if known == 0 { + known, ok := countKnownFields(fields, nonNullableArticleFields) + if !ok || known == 0 { return UpdateArticleBody{}, false } var request UpdateArticleBody @@ -488,12 +492,16 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) { } func (h *Handler) callerIsVerified(r *http.Request) bool { + return isVerifiedCaller(r, h.GetUserUseCase) +} + +func isVerifiedCaller(r *http.Request, getUser *useCases.GetUserUseCase) bool { userId, ok := claims.UserId(r) if !ok { return false } - currentUser, err := h.GetUserUseCase.GetUser(userId) + currentUser, err := getUser.GetUser(userId) if err != nil { return false } @@ -512,6 +520,7 @@ type listQuery struct { Q string `validate:"max=200"` Status string `validate:"omitempty,oneof=online draft"` Site *string `validate:"omitempty,min=1"` + SEO string `validate:"omitempty,oneof=ready suggestions hidden"` Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"` } @@ -562,7 +571,11 @@ func readListQuery(values url.Values) (listQuery, error) { } siteKey = &key } - return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey}, nil + seoStatus := values.Get("seo") + if values.Has("seo") && seoStatus == "" { + return listQuery{}, errors.New("empty seo") + } + return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}, nil } func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { @@ -586,6 +599,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { Status: query.Status, Sort: parseSort(query.Sort), SiteKey: query.Site, + SEO: query.SEO, }) if err != nil { http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError) @@ -594,7 +608,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { responses := make([]listItemResponse, 0, len(page.Items)) for _, article := range page.Items { - response, err := toListItemResponse(article, format) + response, err := toListItemResponse(article, page.SEO[article.ID], format) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return diff --git a/api/handlers/article/seoHandler.go b/api/handlers/article/seoHandler.go new file mode 100644 index 0000000..ff2b2de --- /dev/null +++ b/api/handlers/article/seoHandler.go @@ -0,0 +1,127 @@ +package article + +import ( + "RenewCMS/api/pkg/apperr" + validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/infrastructure/useCases" + "bytes" + "encoding/json" + "io" + "log" + "net/http" +) + +type SEOHandler struct { + ResolveUseCase *useCases.ResolveArticleSEOUseCase + CheckUseCase *useCases.CheckArticleSEOUseCase + GetUserUseCase *useCases.GetUserUseCase +} + +func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase, getUser *useCases.GetUserUseCase) *SEOHandler { + return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check, GetUserUseCase: getUser} +} + +type CheckArticleSEOBody struct { + Body *string `json:"body"` + Slug *string `json:"slug" validate:"omitempty,articleslug"` + Excerpt *string `json:"excerpt" validate:"omitempty,max=300"` + SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"` + SEODescription *string `json:"seo_description" validate:"omitempty,max=320"` + CoverImageID optionalImageID `json:"cover_image_id"` + CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"` + SocialTitle *string `json:"social_title" validate:"omitempty,max=120"` + SocialDescription *string `json:"social_description" validate:"omitempty,max=320"` + SocialImageID optionalImageID `json:"social_image_id"` + CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"` + Noindex *bool `json:"noindex"` + SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"` +} + +var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"} + +func readCheckBody(r *http.Request) (CheckArticleSEOBody, bool) { + raw, err := io.ReadAll(r.Body) + if err != nil { + return CheckArticleSEOBody{}, false + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { + return CheckArticleSEOBody{}, false + } + known, ok := countKnownFields(fields, checkNonNullableFields) + if !ok || known != len(fields) { + return CheckArticleSEOBody{}, false + } + var request CheckArticleSEOBody + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + return CheckArticleSEOBody{}, false + } + return request, true +} + +func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) { + id, err := parseArticleId(r) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + resolved, err := h.ResolveUseCase.Resolve(id, isVerifiedCaller(r, h.GetUserUseCase)) + if err != nil { + writeArticleErrorResponse(w, err) + return + } + writeSEOJson(w, resolved) +} + +func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) { + id, err := parseArticleId(r) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + request, ok := readCheckBody(r) + if !ok { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + if err := validator.Validate.Struct(request); err != nil { + writeValidationError(w, err) + return + } + + checked, err := h.CheckUseCase.Check(id, useCases.CheckArticleSEOCommand{ + Body: request.Body, + Slug: request.Slug, + Excerpt: request.Excerpt, + SEOTitle: request.SEOTitle, + SEODescription: request.SEODescription, + CoverImageID: request.CoverImageID.toNullable(), + CoverImageAlt: request.CoverImageAlt, + SocialTitle: request.SocialTitle, + SocialDescription: request.SocialDescription, + SocialImageID: request.SocialImageID.toNullable(), + CanonicalURL: request.CanonicalURL, + Noindex: request.Noindex, + SEODismissed: request.SEODismissed, + }) + if err != nil { + writeArticleErrorResponse(w, err) + return + } + writeSEOJson(w, checked) +} + +func writeSEOJson(w http.ResponseWriter, payload any) { + encoded, err := json.Marshal(payload) + if err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + if _, err := w.Write(encoded); err != nil { + log.Printf("Failed to write response: %v", err) + } +} diff --git a/api/handlers/site/sitemapHandler.go b/api/handlers/site/sitemapHandler.go new file mode 100644 index 0000000..5d051b4 --- /dev/null +++ b/api/handlers/site/sitemapHandler.go @@ -0,0 +1,87 @@ +package site + +import ( + "RenewCMS/api/pkg/apperr" + validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "log" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-chi/chi/v5" +) + +const ( + defaultSitemapPage = 1 + defaultSitemapSize = 100 + totalCountHeader = "X-Total-Count" +) + +type SitemapHandler struct { + ListUseCase *useCases.ListSitemapUseCase +} + +func NewSitemapHandler(list *useCases.ListSitemapUseCase) *SitemapHandler { + return &SitemapHandler{ListUseCase: list} +} + +type sitemapQuery struct { + Page int `validate:"gte=1"` + Size int `validate:"oneof=25 50 100 500"` +} + +type sitemapItem struct { + URL string `json:"url"` + ModifiedAt string `json:"modified_at"` + Noindex bool `json:"noindex"` +} + +func sitemapIntParam(values url.Values, name string, fallback int) (int, error) { + if !values.Has(name) { + return fallback, nil + } + return strconv.Atoi(values.Get(name)) +} + +func readSitemapQuery(values url.Values) (sitemapQuery, error) { + page, err := sitemapIntParam(values, "page", defaultSitemapPage) + if err != nil { + return sitemapQuery{}, err + } + size, err := sitemapIntParam(values, "size", defaultSitemapSize) + if err != nil { + return sitemapQuery{}, err + } + return sitemapQuery{Page: page, Size: size}, nil +} + +func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) { + query, err := readSitemapQuery(r.URL.Query()) + if err != nil || validator.Validate.Struct(query) != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + + page, err := h.ListUseCase.List(chi.URLParam(r, "key"), query.Page, query.Size) + if err != nil { + writeSiteError(w, err) + return + } + + items := make([]sitemapItem, 0, len(page.Entries)) + for _, entry := range page.Entries { + items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex}) + } + body, err := json.Marshal(items) + if err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + w.Header().Set(totalCountHeader, strconv.Itoa(page.Total)) + if _, err := w.Write(body); err != nil { + log.Printf("Failed to write response: %v", err) + } +} diff --git a/api/router.go b/api/router.go index 4b4dbd3..45aa4ed 100644 --- a/api/router.go +++ b/api/router.go @@ -57,9 +57,12 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase) + seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase, container.GetUserUseCase) + r.Group(func(r chi.Router) { r.Get("/", articleHandler.ListArticles) r.Get("/{id}", articleHandler.GetArticle) + r.Get("/{id}/seo", seoHandler.Resolve) }) r.Group(func(r chi.Router) { r.Use(jwtauth.Authenticator(container.Token)) @@ -70,6 +73,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler r.Patch("/{id}", articleHandler.UpdateArticleBody) r.Patch("/{id}/publish", articleHandler.PublishArticle) r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) + r.Post("/{id}/seo/check", seoHandler.Check) }) return r } @@ -180,6 +184,9 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler { siteHandler := site.NewHandler(container.ListSitesUseCase, container.CreateSiteUseCase, container.UpdateSiteUseCase, container.SetDefaultSiteUseCase, container.DeleteSiteUseCase) + sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase) + + r.Get("/{key}/sitemap", sitemapHandler.Sitemap) r.Group(func(r chi.Router) { r.Use(jwtauth.Verifier(container.Token)) r.Use(container.apiKeyAuth()) @@ -419,7 +426,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) { AllowedOrigins: origins, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, - ExposedHeaders: []string{"Link"}, + ExposedHeaders: []string{"Link", "X-Total-Count"}, AllowCredentials: false, MaxAge: 300, })) diff --git a/docs/api.md b/docs/api.md index 6244a28..4aefef6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,8 +10,9 @@ The origin that serves the app, for example `https://cms.example`. Every path st for example `https://cms.example/v1/article/`. ## Authentication -- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online - articles only. +- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` + and `GET /v1/site/{key}/sitemap`. They return online articles only; the list and the resolved SEO + object also answer drafts to a verified session or API key. - Everything else needs a signed-in browser session (the `jwt` cookie) or an API key: `Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its creator, with that user's role, and shows in full once. @@ -21,14 +22,14 @@ for example `https://cms.example/v1/article/`. under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too. - Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header. -## Routes a key can call (26) +## Routes a key can call (29) | Group | Routes | | --- | --- | -| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | +| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) | | Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me (with the linked `author`) | | Settings | GET /v1/settings | -| Sites | GET /v1/site/ | +| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap | | Authors | GET /v1/author/ | ## Activity log @@ -155,6 +156,20 @@ those fields, a `null` value (except on `cover_image_id` and `social_image_id`) 400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400. +### Resolved SEO, check, list status and sitemap + +RenewCMS renders no page and injects no meta tag: it returns the data a frontend needs. + +| Route | Who | Purpose | +| --- | --- | --- | +| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads drafts | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. | +| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `-N` in `suggestion`, never a 409. | +| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. | + +`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed. + +`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it. + ### Article SEO fields An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by diff --git a/docs/api/articles.md b/docs/api/articles.md index 2ac314b..f810e87 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -36,12 +36,14 @@ Visibility is unchanged: anonymous and unverified callers only see online articl | `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none | | `status` | `online` or `draft` | none | | `site` | the `key` of a site; an unknown key gives an empty page, an empty value is `400` | none | +| `seo` | `ready`, `suggestions` or `hidden`; any other value, an empty one included, is `400` | none | | `sort` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc` (lowercase, for example `title:asc`) | `updated_at:desc` | - `items` is `[]` when empty, never `null`. Each item is the article object below. - Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap. - `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied. - `counts` ignores `q` and `status`, so tab counts do not move while searching. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`. +- Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not. - A page beyond the last answers `200` with `items: []` and the real `total`. - An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first. @@ -117,6 +119,45 @@ Editing a slug that another article of the target site already uses (the new sit Publishing, unpublishing and `noindex` never change a slug. A deleted article frees its slug. +## Resolved SEO object + +`GET /v1/article/{id}/seo` answers the data a frontend renders in its ``. Online articles are public; a draft needs a verified session or API key and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys: + +`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` and `json_ld` (a ready schema.org `Article`). + +The fallback chain, first non-empty wins: + +| Output | Chain | `fallbacks` value | +| --- | --- | --- | +| `title` | `seo_title`, then the article title, then the site `title_template` (`%title%`, `%site%` replaced by `site_name`, result trimmed) | `seo_title`, `title` | +| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` | `seo_description`, `excerpt`, `body`, `site`, `none` | +| `image` | `social_image_id`, `cover_image_id` (with `cover_image_alt`), the site `default_social_image_id`; `url` is the site `base_url` plus the image path, `""` and `none` when there is none | `social`, `cover`, `site`, `none` | +| `canonical_url` | `canonical_url`, then site `base_url` + `/` + `slug` | `canonical_url`, `site` | +| `robots` | `noindex, nofollow` when `noindex` is true, otherwise from the site `default_robots` (`index, follow` or `noindex, nofollow`) | none | +| `og` and `twitter` title and description | `social_title` and `social_description`, then the resolved values | none | + +`og.type` is `article`, `og.locale` is the site locale (`""` when none), `twitter.card` is `summary_large_image` with an image and `summary` without. `published_at` is the creation time of an online article and `null` for a draft; `modified_at` is `updated_at`. `authors[].url` is the author website (`""` when none) and `same_as` lists the social link URLs only. `json_ld.headline` is the meta title without the site template, and `author`, `image` and `datePublished` are left out when empty. + +## SEO status and suggestions + +`POST /v1/article/{id}/seo/check` and the list use one rule set. Soft ranges never block saving or publishing; lengths are counted in characters. + +| Code | Field | Fires when | `params` | +| --- | --- | --- | --- | +| `title_long` | `seo_title` | the meta title (`seo_title`, else the title) is over 60 characters | `length`, `max` | +| `title_empty` | `seo_title` | the meta title is empty after trimming | none | +| `desc_short` | `seo_description` | the resolved description is under 70 characters | `length`, `min` | +| `desc_long` | `seo_description` | the resolved description is over 160 characters | `length`, `max` | +| `cover_alt` | `cover_image_alt` | there is a cover and its alt text is empty | none | +| `cover_none` | `cover_image_id` | there is no cover | none | +| `slug_long` | `slug` | the slug is over 75 characters | `length`, `max` | +| `no_au` | `author_ids` | no author is credited | none | +| `no_h` | `body` | the text has at least 1000 characters and no `h2` to `h6` | none | +| `body_alt` | `body` | an image of the body has no alt text | `count` | +| `short` | `body` | the text has fewer than 300 characters | `length`, `min` | + +Suggestions come in that order. Codes in `seo_dismissed` are never reported or counted. The status is `hidden` when `noindex` is true, else `suggestions` when at least one suggestion remains, else `ready`. The list items carry `seo_status` and `seo_suggestions` (the count, also for a hidden article). + ## Markdown output Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline. diff --git a/docs/release-notes.md b/docs/release-notes.md index 26d16e9..adab14f 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -35,6 +35,13 @@ Articles and sites now store the data a website needs for search and sharing, an - Changed: `GET /v1/media/{id}/usage` stays an array, and every item gains `type` and `fields` without losing `id`, `title` and `published`. Authors and sites that use the image are now listed, without a `published` key. - Changed: a cover image, a social image, an author avatar and a site default social image all count in `used_in` and in the `unused` filter. Deleting an image clears the covers and social images that use it, and merging or purging repoints them. +### New: SEO output + +- `GET /v1/article/{id}/seo` returns the resolved search and sharing data of an article (title with the site template, description, canonical address, robots, Open Graph, Twitter, image, authors and ready JSON-LD), so a frontend can render its meta tags without reimplementing the fallback chain. Drafts answer verified callers only. +- `POST /v1/article/{id}/seo/check` evaluates an article without saving, with optional overrides, and reports the suggestions, the status and whether the slug is free. +- `GET /v1/article/` items carry `seo_status` and `seo_suggestions`, and the new `seo` parameter filters on the status. +- `GET /v1/site/{key}/sitemap` lists the published URLs of a site, paged, with the total in `X-Total-Count`, which is now exposed to browsers through CORS. + ### Upgrading - The first start after the upgrade adds the new columns, gives every existing article a slug generated from its title (in id order within each site, with `-2`, `-3` on conflicts) and creates the unique `(site_id, slug)` index. Nothing else changes, and a second start changes nothing. diff --git a/features/seo_output_backend.feature b/features/seo_output_backend.feature new file mode 100644 index 0000000..0df9185 --- /dev/null +++ b/features/seo_output_backend.feature @@ -0,0 +1,676 @@ +Feature: SEO output backend, resolved SEO object, non-persisting check, list status and sitemap + + As a frontend developer or the RenewCMS back-office editor + I want the resolved SEO data of an article, a non-persisting check, an SEO status on the article list and a sitemap per site + So that a frontend can render meta tags and a sitemap without reimplementing the fallback chain + + Background: + Given the spec of record is docs/design/seo-slices.md (slice 2), docs/design/change-list.md ("SEO round"), docs/design/screens/editor.md, docs/design/screens/articles.md and docs/design/screens/integration.md + And this slice is backend only: no SPA screen and no change to docs/design + And it reuses the article and site SEO fields of features/seo_data_backend.feature and adds no column and no migration + And RenewCMS renders no page and injects no meta tag: it only returns data + And one rule set (a single use-case-level function over the article, its site, its authors and its body) feeds the list seo_status and seo_suggestions, the check endpoint and the suggestion-related parts of the resolver + And the suggestion codes are title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short + And the soft ranges are: meta title 30 to 60 characters, description 70 to 160, slug "very long" above 75; they only produce suggestions and never block saving or publishing + And codes listed in the article's seo_dismissed are never reported and never counted + And the status of an article is "hidden" when noindex is true, otherwise "suggestions" when at least one non-dismissed suggestion remains, otherwise "ready" + And a caller is "verified" when the jwt cookie (or an API key) resolves to a user with is_verified true, and "public" otherwise, exactly as for GET /v1/article/{id} + And the fallback chain is: title = seo_title, then the article title, with the site title_template applied last; description = seo_description, then excerpt, then the first sentence of the body, then the site default_description; image = social_image_id, then cover_image_id, then the site default_social_image_id; canonical = canonical_url, then site base_url + "/" + slug; robots = "noindex, nofollow" when noindex is true, otherwise from the site default_robots; og and twitter title and description = social_title and social_description, then the resolved values + And the sample site 1 is key "renew", base_url "https://www.renew.example", site_name "Renew", title_template "%title% | %site%", default_description "Renovation notes from the Renew team.", default_robots "index", locale "en" and default_social_image_id 7 (path /static/uploadedImages/7.png) + And the sample article 12 is online on the site 1, titled "How we price a bathroom renovation", slug "how-we-price-a-bathroom-renovation", created 2026-09-01T08:00:00Z, updated 2026-09-10T09:30:00Z, with no SEO field set and the body "

We price by the square metre. Tiles cost extra.

" + And error responses are plain text from http.Error: 404 "Article not found" (apperr.ArticleNotFoundErrorMessage), 404 "site_not_found" (apperr.SiteNotFoundErrorMessage), 400 apperr.IdUint32ErrorMessage for a malformed id, 400 apperr.BodyErrorMessage for a malformed body or paging parameter + And request validation goes through validate tags and validator.Validate.Struct + And handlers reach articles, sites, authors and images only through use cases and domain interfaces, and no comment is added anywhere + + Rule: GET /v1/article/{id}/seo returns the resolved object + + Scenario: The object has the documented shape + When an anonymous caller sends GET /v1/article/12/seo + Then the status is 200 + And the body has exactly the keys title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld + And og has exactly the keys type, title, description, image, image_alt, url, site_name and locale + And twitter has exactly the keys card, title, description, image and image_alt + And image has exactly the keys url, alt and source + And fallbacks has exactly the keys title, description, image and canonical + And the body has no body, no id and no seo_dismissed key + + Scenario: A bare article resolves every output through the fallbacks + When an anonymous caller sends GET /v1/article/12/seo + Then title is "How we price a bathroom renovation | Renew" + And description is "We price by the square metre." + And canonical_url is "https://www.renew.example/how-we-price-a-bathroom-renovation" + And robots is "index, follow" + And image is {"url": "https://www.renew.example/static/uploadedImages/7.png", "alt": "", "source": "site"} + And og is {"type": "article", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": "", "url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "site_name": "Renew", "locale": "en"} + And twitter is {"card": "summary_large_image", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": ""} + And fallbacks is {"title": "title", "description": "body", "image": "site", "canonical": "site"} + And published_at is "2026-09-01T08:00:00Z" and modified_at is "2026-09-10T09:30:00Z" + + Scenario Outline: The title chain and the template + Given the article 12 has seo_title "" and the site title_template is "