feat: public API, SEO and existing surfaces
This commit is contained in:
1 parent
45fbbc33b1
commit
c390fcfe8d
103 files changed
+4476
-231
No files matched your search
@@ -58,6 +58,7 @@ type UseCases struct {
|
|||||||
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
|
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
|
||||||
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
|
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
|
||||||
ListSitemapUseCase *useCases.ListSitemapUseCase
|
ListSitemapUseCase *useCases.ListSitemapUseCase
|
||||||
|
ResolvePublicArticleUseCase *useCases.ResolvePublicArticleUseCase
|
||||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||||
CreateTranslationUseCase *useCases.CreateTranslationUseCase
|
CreateTranslationUseCase *useCases.CreateTranslationUseCase
|
||||||
DescribeArticleUseCase *useCases.DescribeArticleUseCase
|
DescribeArticleUseCase *useCases.DescribeArticleUseCase
|
||||||
@@ -403,9 +404,10 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
|||||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
|
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
|
||||||
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo),
|
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo),
|
||||||
MoveStoryUseCase: moveStory,
|
MoveStoryUseCase: moveStory,
|
||||||
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo),
|
||||||
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||||
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
|
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo),
|
||||||
|
ResolvePublicArticleUseCase: useCases.NewResolvePublicArticleUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo, renderer),
|
||||||
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
|
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
|
||||||
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
|
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
|
||||||
CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder),
|
CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder),
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
domain "RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/domain/language"
|
||||||
|
"RenewCMS/internal/domain/site"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PublicHandler struct {
|
||||||
|
resolveUseCase *useCases.ResolvePublicArticleUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublicHandler(resolve *useCases.ResolvePublicArticleUseCase) *PublicHandler {
|
||||||
|
return &PublicHandler{resolveUseCase: resolve}
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicLookupResponse struct {
|
||||||
|
articleResponse
|
||||||
|
Alternates []domain.Alternate `json:"alternates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicStoryResponse struct {
|
||||||
|
articleResponse
|
||||||
|
Alternates []domain.Alternate `json:"alternates"`
|
||||||
|
RequestedLang string `json:"requested_lang"`
|
||||||
|
Fallback bool `json:"fallback"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicStoryQuery struct {
|
||||||
|
Fallback *string `validate:"omitempty,eq=default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicErrors = []struct {
|
||||||
|
cause error
|
||||||
|
message string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{domain.ErrArticleNotFound, apperr.ArticleNotFoundCode, http.StatusNotFound},
|
||||||
|
{domain.ErrLanguageNotAvailable, apperr.LanguageNotAvailableCode, http.StatusNotFound},
|
||||||
|
{site.ErrSiteNotFound, apperr.SiteNotFoundCode, http.StatusNotFound},
|
||||||
|
{language.ErrInvalidLanguage, apperr.InvalidLanguageCode, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePublicError(w http.ResponseWriter, err error) {
|
||||||
|
for _, known := range publicErrors {
|
||||||
|
if errors.Is(err, known.cause) {
|
||||||
|
http.Error(w, known.message, known.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicHandler) Lookup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found, err := h.resolveUseCase.ByLangSlug(useCases.PublicLookupQuery{
|
||||||
|
SiteKey: chi.URLParam(r, "key"),
|
||||||
|
Lang: chi.URLParam(r, "lang"),
|
||||||
|
Slug: chi.URLParam(r, "slug"),
|
||||||
|
Format: format,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writePublicError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.write(w, found, format, func(response articleResponse) any {
|
||||||
|
return publicLookupResponse{articleResponse: response, Alternates: found.Alternates}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicHandler) Story(w http.ResponseWriter, r *http.Request) {
|
||||||
|
storyID, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values := r.URL.Query()
|
||||||
|
query := publicStoryQuery{}
|
||||||
|
if values.Has("fallback") {
|
||||||
|
fallback := values.Get("fallback")
|
||||||
|
query.Fallback = &fallback
|
||||||
|
}
|
||||||
|
if validator.Validate.Struct(query) != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command := useCases.PublicStoryQuery{
|
||||||
|
SiteKey: chi.URLParam(r, "key"),
|
||||||
|
StoryID: uint32(storyID),
|
||||||
|
FallbackDefault: query.Fallback != nil,
|
||||||
|
Format: format,
|
||||||
|
}
|
||||||
|
if values.Has("lang") {
|
||||||
|
requested := values.Get("lang")
|
||||||
|
command.Lang = &requested
|
||||||
|
}
|
||||||
|
found, err := h.resolveUseCase.ByStory(command)
|
||||||
|
if err != nil {
|
||||||
|
writePublicError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.write(w, found, format, func(response articleResponse) any {
|
||||||
|
return publicStoryResponse{articleResponse: response, Alternates: found.Alternates, RequestedLang: found.RequestedLang, Fallback: found.Fallback}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicHandler) write(w http.ResponseWriter, found useCases.PublicArticle, format domain.BodyFormat, shape func(articleResponse) any) {
|
||||||
|
response, err := toArticleResponse(found.Article, format, found.Viewer, found.Context)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(shape(response))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := w.Write(body); err != nil {
|
||||||
|
log.Printf("Failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,8 @@ type usageResponse struct {
|
|||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Published *bool `json:"published,omitempty"`
|
Published *bool `json:"published,omitempty"`
|
||||||
Fields []string `json:"fields"`
|
Fields []string `json:"fields"`
|
||||||
|
StoryID *uint32 `json:"story_id,omitempty"`
|
||||||
|
Lang *string `json:"lang,omitempty"`
|
||||||
|
|
||||||
ContentTypeKey string `json:"content_type_key,omitempty"`
|
ContentTypeKey string `json:"content_type_key,omitempty"`
|
||||||
ContentTypeName string `json:"content_type_name,omitempty"`
|
ContentTypeName string `json:"content_type_name,omitempty"`
|
||||||
@@ -108,7 +110,7 @@ type usageFirstFieldBody struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toUsageResponse(usage useCases.MediaUsage) usageResponse {
|
func toUsageResponse(usage useCases.MediaUsage) usageResponse {
|
||||||
body := usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields, ContentTypeKey: usage.ContentTypeKey, ContentTypeName: usage.ContentTypeName}
|
body := usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields, StoryID: usage.StoryID, Lang: usage.Lang, ContentTypeKey: usage.ContentTypeKey, ContentTypeName: usage.ContentTypeName}
|
||||||
if usage.FirstField != nil {
|
if usage.FirstField != nil {
|
||||||
body.FirstField = &usageFirstFieldBody{Key: usage.FirstField.Key, Type: string(usage.FirstField.Type), Value: usage.FirstField.Value}
|
body.FirstField = &usageFirstFieldBody{Key: usage.FirstField.Key, Type: string(usage.FirstField.Type), Value: usage.FirstField.Value}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type sitemapItem struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ModifiedAt string `json:"modified_at"`
|
ModifiedAt string `json:"modified_at"`
|
||||||
Noindex bool `json:"noindex"`
|
Noindex bool `json:"noindex"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
|
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
|
||||||
@@ -73,7 +74,7 @@ func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
items := make([]sitemapItem, 0, len(page.Entries))
|
items := make([]sitemapItem, 0, len(page.Entries))
|
||||||
for _, entry := range page.Entries {
|
for _, entry := range page.Entries {
|
||||||
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex})
|
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex, Lang: entry.Lang})
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(items)
|
body, err := json.Marshal(items)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -171,3 +171,8 @@ const SlugConflictErrorMessage = "slug_conflict"
|
|||||||
const ConflictingArticlesHeader = "X-Conflicting-Articles"
|
const ConflictingArticlesHeader = "X-Conflicting-Articles"
|
||||||
const StoryMoveRequiredErrorMessage = "story_move_required"
|
const StoryMoveRequiredErrorMessage = "story_move_required"
|
||||||
const StoryBusyErrorMessage = "story_busy"
|
const StoryBusyErrorMessage = "story_busy"
|
||||||
|
|
||||||
|
const ArticleNotFoundCode = "article_not_found"
|
||||||
|
const LanguageNotAvailableCode = "language_not_available"
|
||||||
|
const SiteNotFoundCode = "site_not_found"
|
||||||
|
const InvalidLanguageCode = "invalid_language"
|
||||||
@@ -121,6 +121,16 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewPublicRouter(container *UseCases) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
publicHandler := article.NewPublicHandler(container.ResolvePublicArticleUseCase)
|
||||||
|
|
||||||
|
r.Get("/site/{key}/lang/{lang}/article/{slug}", publicHandler.Lookup)
|
||||||
|
r.Get("/site/{key}/story/{story_id}", publicHandler.Story)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func NewStoryRouter(container *UseCases, trustedProxies []string) http.Handler {
|
func NewStoryRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(container.apiKeyAuth())
|
r.Use(container.apiKeyAuth())
|
||||||
@@ -627,6 +637,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
|||||||
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies))
|
r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies))
|
||||||
})
|
})
|
||||||
|
r.Mount("/public", NewPublicRouter(container))
|
||||||
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||||
|
|||||||
+5
-4
@@ -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/`.
|
for example `https://cms.example/v1/article/`.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
|
- 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, plus the drafts the caller's role may read
|
`GET /v1/site/{key}/sitemap`, `GET /v1/public/site/{key}/lang/{lang}/article/{slug}` and
|
||||||
|
`GET /v1/public/site/{key}/story/{story_id}` (the two language routes ignore any credential and never return a draft, see [api/article-localization.md](api/article-localization.md#public-language-routes)). They return online articles, plus the drafts the caller's role may read
|
||||||
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
|
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
|
||||||
- Everything else needs a signed-in browser session (the `jwt` cookie) or an 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
|
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
|
||||||
@@ -173,9 +174,9 @@ RenewCMS renders no page and injects no meta tag: it returns the data a frontend
|
|||||||
|
|
||||||
| Route | Who | Purpose |
|
| Route | Who | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | 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`. |
|
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld, lang, alternates }`. 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 `<slug>-N` in `suggestion`, never a 409. |
|
| `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 `<slug>-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/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex, lang }]` of the eligible versions (published, language enabled) of the site with that key, by id ascending. `url` is the localized address when the site has a Public URL pattern, otherwise `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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -237,3 +237,97 @@ Statuses of the new and changed calls. Bodies are plain text unless shown as JSO
|
|||||||
## Activity
|
## Activity
|
||||||
|
|
||||||
No new action code. The existing events carry `params.lang` (the language of the version, omitted for a version without language): `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete`. A translation creation records `article.create` on the new version with `params.lang`, `params.story_id` and `params.source_article_id` (only when copied). A story move records one `article.edit` per moved version, with a `site` change (site names before and after) and `params.lang`; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered `404` are not recorded.
|
No new action code. The existing events carry `params.lang` (the language of the version, omitted for a version without language): `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete`. A translation creation records `article.create` on the new version with `params.lang`, `params.story_id` and `params.source_article_id` (only when copied). A story move records one `article.edit` per moved version, with a `site` change (site names before and after) and `params.lang`; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered `404` are not recorded.
|
||||||
|
|
||||||
|
## Public language routes
|
||||||
|
|
||||||
|
Contract for `features/localization_public_api_and_surfaces.feature`. Both routes are open to everyone: a `jwt` cookie or an API key is ignored, so a draft is never returned, whoever asks. They sit under `/v1/public` and are read only.
|
||||||
|
|
||||||
|
A version is **published** when `is_online` is true, and **eligible** when it is published and its language is enabled on the site. A disabled language stays readable on both routes; only the alternates leave it out.
|
||||||
|
|
||||||
|
### Exact lookup: `GET /v1/public/site/{key}/lang/{lang}/article/{slug}`
|
||||||
|
|
||||||
|
Matches the site key, the language and the slug, and nothing else. It never falls back to another language, never follows an old slug and creates no redirect.
|
||||||
|
|
||||||
|
- `lang` is normalized (`FR` reads `fr`, `pt-br` reads `pt-BR`). A value that is not a language tag (`e`, `pt_BR`) is `400 invalid_language`; a valid tag with no version, or outside the supported list, is `404 article_not_found`.
|
||||||
|
- `format` behaves as on `GET /v1/article/{id}`: `markdown` (default), `html` or `json`; anything else is `400` with `The format must be one of: markdown, html, json.`
|
||||||
|
- An unassigned article (`lang` `""`), a draft, a soft-deleted version and a version of another site or language are all `404 article_not_found`, with the same body.
|
||||||
|
- Answers `200` with the body of an anonymous `GET /v1/article/{id}` (same keys and values) and one more key, `alternates`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"lang": "fr",
|
||||||
|
"story_id": 5,
|
||||||
|
"slug": "comment-nous-chiffrons-une-renovation",
|
||||||
|
"is_online": true,
|
||||||
|
"alternates": [
|
||||||
|
{"id": 10, "lang": "en", "story_id": 5, "slug": "how-we-price-a-bathroom-renovation", "url": "https://journal.example.com/en/how-we-price-a-bathroom-renovation"},
|
||||||
|
{"id": 11, "lang": "de", "story_id": 5, "slug": "so-kalkulieren-wir-eine-badsanierung", "url": "https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`alternates` lists the other eligible versions of the story: the served version, drafts and versions in a disabled language are left out, and nothing in the response names them. The order is the default language of the site first, then the other tags in ascending order. Each alternate has exactly `id`, `lang`, `story_id`, `slug` and `url`. `url` is the site Public URL pattern filled with the tag and the slug, or `null` when the site has no pattern. A story with one version answers `[]`.
|
||||||
|
|
||||||
|
### Story resolution: `GET /v1/public/site/{key}/story/{story_id}`
|
||||||
|
|
||||||
|
Resolves one story of the site, optionally with an explicit fallback to the default language. No other language is ever tried.
|
||||||
|
|
||||||
|
| Query | Rule |
|
||||||
|
| --- | --- |
|
||||||
|
| `lang` | optional. Absent, the site default language is requested. A malformed tag is `400 invalid_language` |
|
||||||
|
| `fallback` | optional, only the value `default`. When the requested language has no published version, serve the published default-language version instead. Any other value, an empty one included, is `400` with the body-mismatch message |
|
||||||
|
| `format` | as on the exact lookup |
|
||||||
|
|
||||||
|
The `200` body is the exact lookup body plus `requested_lang` (the language that was asked for, or the default when `lang` is absent) and `fallback` (`true` only when the default-language version replaced the requested one). `lang` is the language of the version served.
|
||||||
|
|
||||||
|
| Request | Answer |
|
||||||
|
| --- | --- |
|
||||||
|
| `?lang=fr`, a published `fr` version exists | the `fr` version, `fallback` false |
|
||||||
|
| `?lang=fr`, none, no `fallback` | `404 language_not_available` |
|
||||||
|
| `?lang=fr&fallback=default`, `fr` missing or unpublished, `en` published | the `en` version, `requested_lang` `fr`, `fallback` true |
|
||||||
|
| `?lang=fr&fallback=default`, neither published | `404 language_not_available` (a third language is never returned) |
|
||||||
|
| `?lang=en&fallback=default`, `en` unpublished | `404 language_not_available` |
|
||||||
|
| no `lang` | the default-language version, `requested_lang` the default |
|
||||||
|
| no `lang`, the site has no confirmed default language | `404 language_not_available` |
|
||||||
|
| a story of another site, an unknown story, a story with no published version | `404 language_not_available` |
|
||||||
|
|
||||||
|
Changing the default language of a site changes only the requests that omit `lang`.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
Plain text, the body is the code followed by a line feed. `language_disabled` is not returned by these routes.
|
||||||
|
|
||||||
|
| Status | Body | When |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 400 | `invalid_language` | `lang` is not a language tag |
|
||||||
|
| 400 | `The request cannot be processed due to a mismatch in the format of the body.` | `fallback` is not `default` |
|
||||||
|
| 400 | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` | `story_id` is not an unsigned integer |
|
||||||
|
| 400 | `The format must be one of: markdown, html, json.` | invalid `format` |
|
||||||
|
| 404 | `article_not_found` | exact lookup: no published version for the site, language and slug |
|
||||||
|
| 404 | `language_not_available` | story resolution: no version can be served under the rules above |
|
||||||
|
| 404 | `site_not_found` | unknown site key |
|
||||||
|
| 500 | `Internal server error` | a repository failure |
|
||||||
|
|
||||||
|
## Resolved SEO and sitemap, language aware
|
||||||
|
|
||||||
|
`GET /v1/article/{id}/seo` keeps its keys and adds `lang`, `alternates` (same shape and order as the public alternates), `og.locale_alternates` and `fallbacks.site_text_skipped`. Every other key keeps its name and type. An unassigned article (`lang` `""`) answers `lang` `""`, `alternates` `[]` and `og.locale_alternates` `[]`, with the legacy canonical and the raw site locale.
|
||||||
|
|
||||||
|
**Canonical.** `canonical_url` and `og.url` are, in order: the version `canonical_url` override (`fallbacks.canonical` `canonical_url`); the site `public_url_pattern` with `{lang}` and `{slug}` filled (`pattern`); the legacy `base_url/slug` (`site`), when the site has no pattern or the article has no language. A region tag is kept as written (`/pt-BR/reforma`). `json_ld.mainEntityOfPage.@id` equals `canonical_url`. Saving a pattern changes no override and no article.
|
||||||
|
|
||||||
|
**og:locale**, first match wins, never invented:
|
||||||
|
|
||||||
|
1. the explicit `social_locale` of the language row;
|
||||||
|
2. the tag already has a region (`pt-BR` gives `pt_BR`);
|
||||||
|
3. `Site.Locale` has the same language part and a region (`en-GB` with `en` gives `en_GB`);
|
||||||
|
4. otherwise `""`, and `og.locale_alternates` is `[]`.
|
||||||
|
|
||||||
|
`og.locale_alternates` lists the social locales of the other eligible versions that resolve one, in the order of the alternates. `GET /v1/site/{key}/languages` reports rule 3 as `social_locale_state` `derived` (`not_set` when `Site.Locale` is bare such as `en`).
|
||||||
|
|
||||||
|
**Fallbacks in the language of the version.** The title and description chains are unchanged except for the site text: `default_description` is treated as written in the current default language of the site, so it is used only by an unassigned article or a version in that language. For any other language the description stays empty, `fallbacks.description` is `none` and `fallbacks.site_text_skipped` is `true`. Changing the default language changes which versions may use the site text. `title_template`, `site_name` and the image chain stay shared; `cover_image_alt` stays per version.
|
||||||
|
|
||||||
|
**Sitemap.** `GET /v1/site/{key}/sitemap` lists every eligible version once (published, language enabled; unassigned published articles keep their legacy entry). Each item is `{url, modified_at, noindex, lang}`; `url` is the localized address when the site has a pattern, otherwise `base_url/slug`. `X-Total-Count` counts the listed items.
|
||||||
|
|
||||||
|
## Media usage
|
||||||
|
|
||||||
|
`GET /v1/media/{id}/usage` article items gain `story_id` and `lang` (`""` for an unassigned article, whose `story_id` is the stored value). There is one article item per version, ordered by id; author, site and entry items carry neither key. `used_in` on `GET /v1/media/` counts a story once, so an image used by three versions of one story is one use. An unassigned article is its own group.
|
||||||
@@ -132,20 +132,20 @@ Publishing, unpublishing and `noindex` never change a slug. A deleted article fr
|
|||||||
|
|
||||||
`GET /v1/article/{id}/seo` answers the data a frontend renders in its `<head>`. Online articles are public; a draft needs a verified session or API key whose role may read it (see [Content permissions](../api.md#content-permissions)) and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys:
|
`GET /v1/article/{id}/seo` answers the data a frontend renders in its `<head>`. Online articles are public; a draft needs a verified session or API key whose role may read it (see [Content permissions](../api.md#content-permissions)) 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`).
|
`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`, `locale_alternates`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` (`title`, `description`, `image`, `canonical`, `site_text_skipped`), `json_ld` (a ready schema.org `Article`), `lang` and `alternates`. The language rules (canonical from the Public URL pattern, `og:locale` order, site text only in the default language) are in [article-localization.md](article-localization.md#resolved-seo-and-sitemap-language-aware).
|
||||||
|
|
||||||
The fallback chain, first non-empty wins:
|
The fallback chain, first non-empty wins:
|
||||||
|
|
||||||
| Output | Chain | `fallbacks` value |
|
| 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` |
|
| `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` |
|
| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` (only for a version in the default language or without language) | `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` |
|
| `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` |
|
| `canonical_url` | `canonical_url`, then the site `public_url_pattern` filled with `lang` and `slug`, then site `base_url` + `/` + `slug` (no pattern, or no language) | `canonical_url`, `pattern`, `site` |
|
||||||
| `robots` | `noindex, nofollow` when `noindex` is true, otherwise from the site `default_robots` (`index, follow` or `noindex, nofollow`) | none |
|
| `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` 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.
|
`og.type` is `article`, `og.locale` is the explicit social locale of the language, else the region of the tag, else the region of the site locale with the same language, else `""`; an article without language keeps the raw site locale, `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
|
## SEO status and suggestions
|
||||||
|
|
||||||
|
|||||||
@@ -66,3 +66,9 @@ Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop.
|
|||||||
## Permissions follow the key's creator
|
## Permissions follow the key's creator
|
||||||
|
|
||||||
A key acts as the user who made it. A key made by a user who is neither owner nor admin cannot do what only owners and admins can, and it answers `403 Only owners and admins can do this.` Check `GET /v1/auth/me` to read the role of the key.
|
A key acts as the user who made it. A key made by a user who is neither owner nor admin cannot do what only owners and admins can, and it answers `403 Only owners and admins can do this.` Check `GET /v1/auth/me` to read the role of the key.
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
- Every article version has a `lang` and a `story_id`; the versions of one article share the `story_id`. List with `lang=fr` to filter a language, and the MCP `list_articles` tool takes `lang`.
|
||||||
|
- `GET /v1/public/site/{key}/lang/{lang}/article/{slug}` and `GET /v1/public/site/{key}/story/{story_id}` return only published versions, with `alternates`. Fallback is never silent: ask for it with `fallback=default`, and only the default language is tried.
|
||||||
|
- `GET /v1/article/{id}/seo` adds `lang`, `alternates`, `og.locale_alternates` and `fallbacks.site_text_skipped`. The canonical follows the site Public URL pattern unless the version overrides it.
|
||||||
@@ -79,3 +79,14 @@ curl "{{BASE_URL}}/v1/article/1?format=markdown"
|
|||||||
```
|
```
|
||||||
|
|
||||||
Before step 4 this answers `404` with the text `Article not found`, because anonymous callers only see online articles.
|
Before step 4 this answers `404` with the text `Article not found`, because anonymous callers only see online articles.
|
||||||
|
|
||||||
|
## Read a language version (public)
|
||||||
|
|
||||||
|
Two public routes, no credential needed (a cookie or key is ignored and a draft is never returned):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
|
||||||
|
GET /v1/public/site/journal/story/5?lang=fr&fallback=default
|
||||||
|
```
|
||||||
|
|
||||||
|
The first matches site, language and slug exactly and never falls back (`404 article_not_found`). The second resolves a story: without `lang` it asks for the site default language, and `fallback=default` serves the default-language version when the requested one is not published (`fallback: true`, `requested_lang` keeps the asked language); otherwise `404 language_not_available`. Both answer the article object plus `alternates`, the other published versions of the story in enabled languages (`id`, `lang`, `story_id`, `slug`, `url`; `url` is `null` when the site has no Public URL pattern), default language first, then by tag. An invalid `lang` is `400 invalid_language`.
|
||||||
@@ -20,3 +20,7 @@ All of them are public. `{{VERSION}}` and `{{BASE_URL}}` in the sources are repl
|
|||||||
- Recipes in `docs/llm/` hold real responses. Re-capture them when a response shape changes.
|
- Recipes in `docs/llm/` hold real responses. Re-capture them when a response shape changes.
|
||||||
|
|
||||||
`TestRouterRoutesAreInTheCatalogue` fails when a route of `api/router.go` is missing from the catalogue. Routes still undocumented are listed in `routesNotYetInTheCatalogue` in `test/api_catalogue_drift_test.go`; remove an entry once the route is documented.
|
`TestRouterRoutesAreInTheCatalogue` fails when a route of `api/router.go` is missing from the catalogue. Routes still undocumented are listed in `routesNotYetInTheCatalogue` in `test/api_catalogue_drift_test.go`; remove an entry once the route is documented.
|
||||||
|
|
||||||
|
## Languages in the generated output
|
||||||
|
|
||||||
|
The article object carries `lang` and `story_id`, and the public language routes `GET /v1/public/site/{key}/lang/{lang}/article/{slug}` and `GET /v1/public/site/{key}/story/{story_id}` return `alternates` and, for the second, `requested_lang` and `fallback`. They are described in `docs/api/article-localization.md`, in the catalogue and in the recipes, so `/llms-full.txt` and `/openapi.json` include them after `task build:llms`.
|
||||||
@@ -18,6 +18,7 @@ Feature: Integration screen (API reference)
|
|||||||
| Media | 11 | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET and PATCH /v1/media/purge-settings, GET /v1/media/purge-preview, POST /v1/media/purge |
|
| Media | 11 | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET and PATCH /v1/media/purge-settings, GET /v1/media/purge-preview, POST /v1/media/purge |
|
||||||
| Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me |
|
| Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me |
|
||||||
| Settings | 1 | GET /v1/settings |
|
| Settings | 1 | GET /v1/settings |
|
||||||
|
And the catalogue also lists, as public requests, GET /v1/public/site/{key}/lang/{lang}/article/{slug} and GET /v1/public/site/{key}/story/{story_id}, and documents the new keys of GET /v1/article/{id}/seo, as in features/localization_public_api_and_surfaces.feature (the counts above grow by those two public routes)
|
||||||
And the "Session only" group has 37 non-expandable rows in 6 sub-groups (the full list is in features/integration_api_key_routes.feature); the first three are:
|
And the "Session only" group has 37 non-expandable rows in 6 sub-groups (the full list is in features/integration_api_key_routes.feature); the first three are:
|
||||||
| sub-group | rows |
|
| sub-group | rows |
|
||||||
| System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart |
|
| System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart |
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
|
|||||||
created_at, used_in, duplicate;
|
created_at, used_in, duplicate;
|
||||||
- media list: items, total, page, size, counts (all, unused, duplicates), no next_cursor;
|
- media list: items, total, page, size, counts (all, unused, duplicates), no next_cursor;
|
||||||
- merge: articles_updated, merged;
|
- merge: articles_updated, merged;
|
||||||
- usage: id, title, published;
|
- usage: type, id, title, published, fields, story_id, lang;
|
||||||
|
- the two public language routes and the new keys of GET /v1/article/{id}/seo are in the catalogue (features/localization_public_api_and_surfaces.qa.md, item 9);
|
||||||
- user list item: id, kind, username, display_name, email, role, status, created_at;
|
- user list item: id, kind, username, display_name, email, role, status, created_at;
|
||||||
- invite: id, kind, email, status, created_at, expires_at;
|
- invite: id, kind, email, status, created_at, expires_at;
|
||||||
- settings: version.
|
- settings: version.
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
Feature: Localized public API, SEO output and the existing surfaces that show languages
|
||||||
|
A visitor or a frontend reads one published version by site, language and slug, or
|
||||||
|
resolves an article group (story) with an explicit fallback, and gets the other
|
||||||
|
published languages as alternates. The resolved SEO object, the sitemap and the
|
||||||
|
back-office surfaces that already show articles become language aware. Nothing
|
||||||
|
falls back silently, no draft is ever returned and no hidden sibling is named.
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the design of record is docs/design/localization.md (Public lookup and URLs, Locale and SEO corrections), docs/design/localization-tables.md, docs/design/screens/localization-metadata-api.md (L10) and docs/design/screens/localization-surfaces.md (L12)
|
||||||
|
And the language rules, slug uniqueness per (site, lang) and unique (story_id, lang) of features/article_localization_storage.feature apply
|
||||||
|
And the site languages, default and social locale of features/site_languages.feature and the public_url_pattern of features/site_public_url_pattern.feature already exist
|
||||||
|
And error responses are plain text whose body is the snake_case code, or the existing apperr text where the route already used one
|
||||||
|
And a version is "published" when is_online is true, and "eligible" when it is published and its language is enabled on the site
|
||||||
|
And the public routes are open to everyone: a jwt cookie or API key is ignored, so a verified user never gets a draft from them
|
||||||
|
And handlers reach articles, sites and languages only through use cases and domain interfaces, validation goes through validate tags and validator.Validate.Struct, and no comment is added anywhere
|
||||||
|
And site "journal" (id 1, key "journal", base_url "https://www.journal.example", Locale "en-GB", default_description "Renovation notes.", public_url_pattern "https://journal.example.com/{lang}/{slug}") has these languages
|
||||||
|
| lang | default | enabled | social_locale |
|
||||||
|
| en | true | true | null |
|
||||||
|
| fr | false | true | fr_FR |
|
||||||
|
| de | false | true | null |
|
||||||
|
| ar | false | true | null |
|
||||||
|
| es | false | false | null |
|
||||||
|
And story 5 of journal holds these versions
|
||||||
|
| id | lang | slug | published | title |
|
||||||
|
| 10 | en | how-we-price-a-bathroom-renovation | yes | How we price a bathroom renovation |
|
||||||
|
| 12 | fr | comment-nous-chiffrons-une-renovation | yes | Comment nous chiffrons une rénovation |
|
||||||
|
| 11 | de | so-kalkulieren-wir-eine-badsanierung | yes | So kalkulieren wir eine Badsanierung |
|
||||||
|
| 13 | ar | takalif | no | Takalif |
|
||||||
|
| 14 | es | cuanto-cuesta | yes | Cuánto cuesta |
|
||||||
|
And story 6 of journal holds only the published en version 20 with slug "spring"
|
||||||
|
And story 7 of journal holds only the unpublished en version 21 with slug "winter"
|
||||||
|
And article 30 of journal is unassigned (lang "") and published with slug "legacy-note"
|
||||||
|
|
||||||
|
Rule: Exact lookup by site, language and slug never falls back
|
||||||
|
|
||||||
|
Scenario: A published version is returned with its alternates
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
|
||||||
|
Then the status is 200
|
||||||
|
And the body is the JSON of GET /v1/article/12 for an anonymous caller, with the same keys and values (id 12, lang "fr", story_id 5, slug, is_online true)
|
||||||
|
And the body has one more key "alternates", an array of the other eligible versions of story 5
|
||||||
|
And alternates is ordered default language first, then by tag: en (10), de (11)
|
||||||
|
And each alternate has exactly the keys id, lang, story_id, slug and url
|
||||||
|
And the url of the en alternate is "https://journal.example.com/en/how-we-price-a-bathroom-renovation"
|
||||||
|
|
||||||
|
Scenario: Alternates leave out the served version, drafts and disabled languages
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/how-we-price-a-bathroom-renovation
|
||||||
|
Then alternates lists fr (12) and de (11)
|
||||||
|
And it does not list ar (13, unpublished) nor es (14, language disabled)
|
||||||
|
And no key or message anywhere in the response mentions version 13
|
||||||
|
|
||||||
|
Scenario: A disabled language stays readable by exact lookup
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/es/article/cuanto-cuesta
|
||||||
|
Then the status is 200 and lang is "es"
|
||||||
|
And alternates lists en, fr and de
|
||||||
|
|
||||||
|
Scenario: A story with one version has an empty alternates array
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring
|
||||||
|
Then the status is 200 and alternates is []
|
||||||
|
|
||||||
|
Scenario Outline: The lookup matches site, language and slug only
|
||||||
|
When an anonymous client sends GET /v1/public/site/<key>/lang/<lang>/article/<slug>
|
||||||
|
Then the status is <status> and the body is "<body>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| key | lang | slug | status | body |
|
||||||
|
| journal | de | comment-nous-chiffrons-une-renovation | 404 | article_not_found |
|
||||||
|
| journal | fr | how-we-price-a-bathroom-renovation | 404 | article_not_found |
|
||||||
|
| journal | ar | takalif | 404 | article_not_found |
|
||||||
|
| journal | en | winter | 404 | article_not_found |
|
||||||
|
| journal | en | nope | 404 | article_not_found |
|
||||||
|
| journal | FR | comment-nous-chiffrons-une-renovation | 200 | |
|
||||||
|
| journal | pt-br | spring | 404 | article_not_found |
|
||||||
|
| journal | xx | spring | 404 | article_not_found |
|
||||||
|
| journal | e | spring | 400 | invalid_language |
|
||||||
|
| journal | pt_BR | spring | 400 | invalid_language |
|
||||||
|
| nowhere | fr | comment-nous-chiffrons-une-renovation | 404 | site_not_found |
|
||||||
|
|
||||||
|
Scenario: The same slug in another site or language is a different page
|
||||||
|
Given site "atelier" holds a published fr version with slug "comment-nous-chiffrons-une-renovation"
|
||||||
|
When an anonymous client sends GET /v1/public/site/atelier/lang/fr/article/comment-nous-chiffrons-une-renovation
|
||||||
|
Then the status is 200 and site_id is atelier's
|
||||||
|
And the same request on journal still answers version 12
|
||||||
|
|
||||||
|
Scenario: An unassigned article has no language route
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/legacy-note
|
||||||
|
Then the status is 404 and the body is "article_not_found"
|
||||||
|
And GET /v1/article/30 still answers 200 as before
|
||||||
|
|
||||||
|
Scenario: A draft never answers, even to its owner
|
||||||
|
Given the owner has a valid jwt cookie
|
||||||
|
When the client sends GET /v1/public/site/journal/lang/en/article/winter with that cookie
|
||||||
|
Then the status is 404 and the body is "article_not_found"
|
||||||
|
|
||||||
|
Scenario: Unpublishing removes the version at once
|
||||||
|
Given version 12 is unpublished
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
|
||||||
|
Then the status is 404 and the body is "article_not_found"
|
||||||
|
And version 12 is no longer in the alternates of version 10
|
||||||
|
|
||||||
|
Scenario: A changed slug breaks the old address and creates no redirect
|
||||||
|
Given the slug of version 12 is changed to "nouveau-titre"
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
|
||||||
|
Then the status is 404 and the body is "article_not_found"
|
||||||
|
And GET /v1/public/site/journal/lang/fr/article/nouveau-titre answers 200
|
||||||
|
|
||||||
|
Scenario: The body format parameter behaves as on GET /v1/article/{id}
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring?format=json
|
||||||
|
Then body_format is "json"
|
||||||
|
And ?format=pdf answers 400 with the existing body format message
|
||||||
|
|
||||||
|
Scenario: A repository failure answers 500
|
||||||
|
Given the article repository fails on its next call
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring
|
||||||
|
Then the status is 500 and the body is "Internal server error"
|
||||||
|
|
||||||
|
Rule: Story resolution falls back only when asked and only to the default language
|
||||||
|
|
||||||
|
Scenario: The requested language is published
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr
|
||||||
|
Then the status is 200 and the article is version 12
|
||||||
|
And lang is "fr", requested_lang is "fr" and fallback is false
|
||||||
|
And alternates lists en and de
|
||||||
|
|
||||||
|
Scenario: The requested language is missing and no fallback is asked
|
||||||
|
Given story 5 has no published fr version
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr
|
||||||
|
Then the status is 404 and the body is "language_not_available"
|
||||||
|
|
||||||
|
Scenario: The requested language is missing and the default is the fallback
|
||||||
|
Given story 5 has no published fr version
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
|
||||||
|
Then the status is 200 and the article is version 10
|
||||||
|
And lang is "en", requested_lang is "fr" and fallback is true
|
||||||
|
|
||||||
|
Scenario: The requested language is unpublished, not only missing
|
||||||
|
Given version 12 is unpublished
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
|
||||||
|
Then the article is version 10 and fallback is true
|
||||||
|
|
||||||
|
Scenario: Neither the requested language nor the default is published
|
||||||
|
Given versions 12 and 10 are unpublished
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
|
||||||
|
Then the status is 404 and the body is "language_not_available"
|
||||||
|
And version 11 (de, published) is not returned: no third language is tried
|
||||||
|
|
||||||
|
Scenario: The default language itself is requested and unpublished
|
||||||
|
Given version 10 is unpublished
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=en&fallback=default
|
||||||
|
Then the status is 404 and the body is "language_not_available"
|
||||||
|
|
||||||
|
Scenario: No language means the site default language
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5
|
||||||
|
Then the article is version 10, lang is "en", requested_lang is "en" and fallback is false
|
||||||
|
|
||||||
|
Scenario: Changing the default changes only requests that omit the language
|
||||||
|
Given the owner makes fr the default language of journal
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5
|
||||||
|
Then the article is version 12 and requested_lang is "fr"
|
||||||
|
And GET /v1/public/site/journal/story/5?lang=en still answers version 10
|
||||||
|
And GET /v1/public/site/journal/lang/en/article/how-we-price-a-bathroom-renovation still answers version 10
|
||||||
|
And GET /v1/article/10 still answers version 10
|
||||||
|
|
||||||
|
Scenario: A story of a site with no confirmed default cannot be resolved without a language
|
||||||
|
Given site "notes" has no confirmed default language
|
||||||
|
When an anonymous client sends GET /v1/public/site/notes/story/<story>
|
||||||
|
Then the status is 404 and the body is "language_not_available"
|
||||||
|
And the same request with an explicit lang that matches a published version answers 200
|
||||||
|
|
||||||
|
Scenario: A disabled language is served when asked explicitly
|
||||||
|
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=es
|
||||||
|
Then the status is 200 and lang is "es"
|
||||||
|
|
||||||
|
Scenario Outline: Refused and unknown requests
|
||||||
|
When an anonymous client sends GET /v1/public/site/<key>/story/<story>?<query>
|
||||||
|
Then the status is <status> and the body is "<body>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| key | story | query | status | body |
|
||||||
|
| journal | 6 | lang=fr | 404 | language_not_available |
|
||||||
|
| journal | 7 | lang=en | 404 | language_not_available |
|
||||||
|
| journal | 999 | lang=en | 404 | language_not_available |
|
||||||
|
| journal | 5 | lang=fr&fallback=latest | 400 | apperr.BodyErrorMessage |
|
||||||
|
| journal | 5 | lang=fr&fallback= | 400 | apperr.BodyErrorMessage |
|
||||||
|
| journal | 5 | lang=english | 400 | invalid_language |
|
||||||
|
| journal | abc | lang=en | 400 | apperr.IdUint32ErrorMessage |
|
||||||
|
| nowhere | 5 | lang=en | 404 | site_not_found |
|
||||||
|
|
||||||
|
Scenario: A story of another site is not found
|
||||||
|
When an anonymous client sends GET /v1/public/site/atelier/story/5?lang=en
|
||||||
|
Then the status is 404 and the body is "language_not_available"
|
||||||
|
|
||||||
|
Scenario: The response carries no more than a direct read would
|
||||||
|
When an anonymous client resolves story 5 with lang=fr
|
||||||
|
Then the keys are those of the exact lookup plus requested_lang and fallback
|
||||||
|
And created_by, capabilities and every draft or sibling detail are absent as on an anonymous GET /v1/article/{id}
|
||||||
|
|
||||||
|
Scenario: Existing article routes are unchanged
|
||||||
|
Then GET /v1/article/{id}, GET /v1/article/ and PATCH /v1/article/{id}/publish keep their statuses, bodies and access rules
|
||||||
|
|
||||||
|
Rule: The resolved SEO object is language aware
|
||||||
|
|
||||||
|
Scenario: The shape grows by additive keys
|
||||||
|
When an anonymous caller sends GET /v1/article/12/seo
|
||||||
|
Then the body has the keys of features/seo_output_backend.feature plus "lang" and "alternates"
|
||||||
|
And og has the keys of that feature plus "locale_alternates"
|
||||||
|
And fallbacks has the keys of that feature plus "site_text_skipped"
|
||||||
|
And lang is "fr" and alternates has the shape and the order of the public alternates
|
||||||
|
|
||||||
|
Scenario: An unassigned article keeps the legacy values
|
||||||
|
When an anonymous caller sends GET /v1/article/30/seo
|
||||||
|
Then lang is "" and alternates is [] and og.locale_alternates is []
|
||||||
|
And canonical_url is "https://www.journal.example/legacy-note" because {lang} cannot be filled, whatever the pattern
|
||||||
|
And og.locale is the raw Site.Locale "en-GB" as before this slice
|
||||||
|
|
||||||
|
Scenario Outline: The canonical is the localized page unless overridden
|
||||||
|
Given journal's public_url_pattern is <pattern>
|
||||||
|
And the version <id> has canonical_url "<override>"
|
||||||
|
When an anonymous caller sends GET /v1/article/<id>/seo
|
||||||
|
Then canonical_url and og.url are "<canonical>" and fallbacks.canonical is "<source>"
|
||||||
|
And json_ld.mainEntityOfPage.@id equals canonical_url
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| pattern | id | override | canonical | source |
|
||||||
|
| https://journal.example.com/{lang}/{slug} | 12 | | https://journal.example.com/fr/comment-nous-chiffrons-une-renovation | pattern |
|
||||||
|
| https://journal.example.com/{lang}/articles/{slug} | 11 | | https://journal.example.com/de/articles/so-kalkulieren-wir-eine-badsanierung | pattern |
|
||||||
|
| https://journal.example.com/{lang}/{slug} | 12 | https://other.example/a | https://other.example/a | canonical_url |
|
||||||
|
| null | 12 | | https://www.journal.example/comment-nous-chiffrons-une-renovation | site |
|
||||||
|
| https://journal.example.com/{slug} | 12 | | https://journal.example.com/comment-nous-chiffrons-une-renovation | pattern |
|
||||||
|
|
||||||
|
Scenario: A region tag is kept in the address
|
||||||
|
Given a version in pt-BR with slug "reforma" on a site whose pattern holds {lang}
|
||||||
|
Then its canonical contains "/pt-BR/reforma"
|
||||||
|
|
||||||
|
Scenario: Saving the pattern now changes canonical output
|
||||||
|
Given journal has no pattern and the canonical of version 12 is "https://www.journal.example/comment-nous-chiffrons-une-renovation"
|
||||||
|
When an owner sets the pattern "https://journal.example.com/{lang}/{slug}"
|
||||||
|
Then the canonical of version 12 becomes "https://journal.example.com/fr/comment-nous-chiffrons-une-renovation"
|
||||||
|
And a canonical override on any version is unchanged
|
||||||
|
|
||||||
|
Scenario Outline: og:locale is derived in a fixed order and never invented
|
||||||
|
Given the site has Locale "<site_locale>", the language row "<lang>" has social_locale <explicit>
|
||||||
|
When an anonymous caller sends GET /v1/article/<id>/seo for a published version in <lang>
|
||||||
|
Then og.locale is "<og_locale>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| site_locale | lang | explicit | id | og_locale | rule |
|
||||||
|
| en-GB | fr | "fr_FR" | 12 | fr_FR | 1 explicit social locale |
|
||||||
|
| en-GB | fr | "fr_CA" | 12 | fr_CA | explicit wins over every other rule |
|
||||||
|
| en-GB | pt-BR | null | 40 | pt_BR | 2 the tag already has a region |
|
||||||
|
| en-GB | en | null | 10 | en_GB | 3 Site.Locale has the same language part |
|
||||||
|
| fr-CA | fr | null | 12 | fr_CA | 3 |
|
||||||
|
| en-GB | de | null | 11 | | 4 nothing resolves, the value is omitted |
|
||||||
|
| en | en | null | 10 | | 4 a bare Site.Locale has no region to borrow |
|
||||||
|
| null | de | null | 11 | | 4 |
|
||||||
|
| en-GB | fr-CA | "fr_FR" | 41 | fr_FR | explicit wins over the tag's region |
|
||||||
|
|
||||||
|
Scenario: An unresolved og:locale is an empty string and its alternates are left out
|
||||||
|
Given the version 11 (de) resolves no social locale
|
||||||
|
When an anonymous caller sends GET /v1/article/11/seo
|
||||||
|
Then og.locale is "" and og.locale_alternates is []
|
||||||
|
|
||||||
|
Scenario: og:locale:alternate lists the other eligible versions that resolve
|
||||||
|
When an anonymous caller sends GET /v1/article/10/seo
|
||||||
|
Then og.locale is "en_GB"
|
||||||
|
And og.locale_alternates is ["fr_FR"]
|
||||||
|
And de is left out because it resolves no social locale, ar and es because they are not eligible
|
||||||
|
|
||||||
|
Scenario Outline: SEO fields fall back in the language of the version
|
||||||
|
Given version 12 (fr) has seo_title "<seo_title>", seo_description "<seo_description>", excerpt "<excerpt>" and a body whose first sentence is "<sentence>"
|
||||||
|
And journal's default_description is "Renovation notes." written in the default language "en"
|
||||||
|
When an anonymous caller sends GET /v1/article/12/seo
|
||||||
|
Then title is "<title>" and fallbacks.title is "<title_source>"
|
||||||
|
And description is "<description>" and fallbacks.description is "<description_source>"
|
||||||
|
And fallbacks.site_text_skipped is <skipped>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| seo_title | seo_description | excerpt | sentence | title | title_source | description | description_source | skipped |
|
||||||
|
| Mon titre | Ma description. | Un résumé. | Première. | Mon titre \| Journal | seo_title | Ma description. | seo_description | false |
|
||||||
|
| | | Un résumé. | Première. | Comment nous chiffrons une rénovation \| Journal | title | Un résumé. | excerpt | false |
|
||||||
|
| | | | Première. | Comment nous chiffrons une rénovation \| Journal | title | Première. | body | false |
|
||||||
|
| | | | | Comment nous chiffrons une rénovation \| Journal | title | | none | true |
|
||||||
|
|
||||||
|
Scenario Outline: Site default text applies only to articles in its own language
|
||||||
|
Given journal's default_description is "Renovation notes." and the version has no description, excerpt or body sentence
|
||||||
|
When an anonymous caller sends GET /v1/article/<id>/seo for the version in <lang>
|
||||||
|
Then description is "<description>" and fallbacks.description is "<source>"
|
||||||
|
And fallbacks.site_text_skipped is <skipped>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| id | lang | description | source | skipped |
|
||||||
|
| 10 | en | Renovation notes. | site | false |
|
||||||
|
| 12 | fr | | none | true |
|
||||||
|
| 30 | (none) | Renovation notes. | site | false |
|
||||||
|
|
||||||
|
Scenario: A site without a default description withholds nothing
|
||||||
|
Given journal has no default_description
|
||||||
|
When an anonymous caller sends GET /v1/article/12/seo
|
||||||
|
Then fallbacks.description is "none" and fallbacks.site_text_skipped is false
|
||||||
|
|
||||||
|
Scenario: Changing the default language changes which articles may use the site text
|
||||||
|
Given the owner makes fr the default language of journal
|
||||||
|
When an anonymous caller sends GET /v1/article/10/seo for the en version without description
|
||||||
|
Then fallbacks.site_text_skipped is true
|
||||||
|
And this is the documented limitation of treating the site text as written in the current default language
|
||||||
|
|
||||||
|
Scenario: Brand text and images stay shared
|
||||||
|
Then title_template and site_name apply to every language
|
||||||
|
And the image chain (social, cover, site default image) is unchanged and each version's cover_image_alt stays its own
|
||||||
|
|
||||||
|
Scenario: The SEO read stays idempotent and public
|
||||||
|
Then the visibility rules, 404 for a draft to a non-verified caller and 400 for a malformed id of features/seo_output_backend.feature are unchanged
|
||||||
|
And no repository write and no activity event happen
|
||||||
|
|
||||||
|
Rule: The sitemap lists every eligible version at its localized address
|
||||||
|
|
||||||
|
Scenario: Each eligible version is listed once
|
||||||
|
When an anonymous caller sends GET /v1/site/journal/sitemap
|
||||||
|
Then the items are the published versions of journal in enabled languages
|
||||||
|
And each item has the keys url, modified_at and noindex plus "lang"
|
||||||
|
And the url of version 12 is "https://journal.example.com/fr/comment-nous-chiffrons-une-renovation"
|
||||||
|
And the versions 14 (es, disabled), 13 and 21 (drafts) are absent
|
||||||
|
And X-Total-Count counts the listed items
|
||||||
|
|
||||||
|
Scenario: Without a pattern the legacy address is kept
|
||||||
|
Given journal has no pattern
|
||||||
|
When an anonymous caller sends GET /v1/site/journal/sitemap
|
||||||
|
Then the url of version 20 is "https://www.journal.example/spring" as before this slice
|
||||||
|
|
||||||
|
Rule: Media usage names the language and counts a story once
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given image 7 "bathroom.jpg" is the cover of versions 10 (en), 12 (fr) and 11 (de) of story 5, and is in the body of version 12
|
||||||
|
And image 7 is also the body image of article 20 (story 6, en) and of unassigned article 30
|
||||||
|
And image 7 is the default_social_image of site journal and the avatar of author 3
|
||||||
|
|
||||||
|
Scenario: Article usage items carry lang and story_id
|
||||||
|
When the client sends GET /v1/media/7/usage with a valid cookie
|
||||||
|
Then every item of type "article" has the existing keys type, id, title, published and fields plus "story_id" and "lang"
|
||||||
|
And there is one article item per version, ordered by id: 10, 11, 12, 20, 30
|
||||||
|
And item 12 is {"type": "article", "id": 12, "title": "Comment nous chiffrons une rénovation", "published": true, "fields": ["body", "cover"], "story_id": 5, "lang": "fr"}
|
||||||
|
And item 20 has story_id 6 and lang "en"
|
||||||
|
|
||||||
|
Scenario: An unassigned article has no language and is its own group
|
||||||
|
When the client sends GET /v1/media/7/usage
|
||||||
|
Then item 30 has "lang" "" and its own story_id as stored, and is never grouped with another article
|
||||||
|
|
||||||
|
Scenario: Items of other types are unchanged
|
||||||
|
When the client sends GET /v1/media/7/usage
|
||||||
|
Then the author, site and entry items carry neither "lang" nor "story_id"
|
||||||
|
And the order stays articles, authors, sites, then entries as in features/media_field_library.feature
|
||||||
|
|
||||||
|
Scenario: An image used by 3 versions of one story is one use
|
||||||
|
When the client sends GET /v1/media/7/usage
|
||||||
|
Then the three items 10, 11 and 12 share story_id 5, so a consumer counts them as one article
|
||||||
|
And GET /v1/media/ reports used_in 5 for image 7: story 5, story 6, article 30, the author and the site
|
||||||
|
|
||||||
|
Scenario: A single-language site is unchanged for existing consumers
|
||||||
|
Given site "notes" has one language
|
||||||
|
When the client sends GET /v1/media/8/usage for an image used by one article of notes
|
||||||
|
Then the item has the existing keys plus story_id and lang, and the count is the same as before this slice
|
||||||
|
|
||||||
|
Scenario: The usage of an unused image is still an empty array
|
||||||
|
When the client sends GET /v1/media/4/usage
|
||||||
|
Then the status is 200 and the body is []
|
||||||
|
|
||||||
|
Scenario: The delete-in-use dialog counts stories
|
||||||
|
Given image 7 is open in the Delete dialog of the Media library
|
||||||
|
Then the article count in the copy and the lists is 2 articles (story 5, story 6) plus the unassigned article 30, not 5 versions
|
||||||
|
|
||||||
|
Scenario: The Media usage panel groups versions under their article (loc.l12.media)
|
||||||
|
When the user opens the usage of image 7 in Media on journal
|
||||||
|
Then the Articles section lists story 5 once, with the title of its default-language version and the language of each version that uses the image (EN, DE, FR)
|
||||||
|
And the Articles heading counts articles, not versions
|
||||||
|
And a one-language site lists articles exactly as before
|
||||||
|
|
||||||
|
Rule: Language configuration shows the derived social locale
|
||||||
|
|
||||||
|
Scenario: A bare tag borrows the region of Site.Locale
|
||||||
|
Given site "atelier" has Locale "en-GB" and a language "en" with social_locale null
|
||||||
|
When an owner reads GET /v1/site/atelier/languages
|
||||||
|
Then language "en" has social_locale_state "derived" and an effective value "en_GB"
|
||||||
|
And this replaces the deferral in features/site_languages.feature ("Only the explicit value and the tag's own region resolve the social locale")
|
||||||
|
|
||||||
|
Scenario: No region anywhere stays not set
|
||||||
|
Given site "atelier" has Locale "en" and a language "en" with social_locale null
|
||||||
|
Then language "en" has social_locale_state "not_set" and an effective value null
|
||||||
|
|
||||||
|
Rule: Integration page requests
|
||||||
|
|
||||||
|
Scenario: The catalogue documents the new requests
|
||||||
|
Then web/src/data/apiCatalogue.ts lists GET /v1/public/site/{key}/lang/{lang}/article/{slug} and GET /v1/public/site/{key}/story/{story_id}
|
||||||
|
And it documents alternates, requested_lang, fallback, the errors article_not_found, language_not_available, site_not_found and invalid_language, and the new keys of GET /v1/article/{id}/seo
|
||||||
|
And the catalogue sentence "Titles can't be changed after creation." is replaced by one that matches the editable title
|
||||||
|
And the catalogue counts in web/src/views/IntegrationView.spec.ts and the drift test test/api_catalogue_drift_test.go are updated
|
||||||
|
|
||||||
|
Rule: MCP server and llms output
|
||||||
|
|
||||||
|
Scenario: The MCP tools expose the language
|
||||||
|
Then the tool list_articles accepts an optional "lang" input that is sent as the lang query parameter
|
||||||
|
And the tool descriptions no longer say that a title cannot be changed
|
||||||
|
And get_article, list_articles and get_sitemap return the JSON they already return, which now carries lang and story_id
|
||||||
|
And no tool is added for the public routes
|
||||||
|
|
||||||
|
Scenario: The llms documents mention languages
|
||||||
|
Then docs/llms.md, docs/llm/ and the llms-full output describe lang, story_id, alternates, the two public routes and the fallback rule
|
||||||
|
And the generated output stays in sync (task build:llms leaves no diff)
|
||||||
|
|
||||||
|
Rule: SPA, Search and sharing panel (L10)
|
||||||
|
|
||||||
|
Scenario Outline: The panel of a localized version
|
||||||
|
Given the editor is open on <version> of a site with the languages above
|
||||||
|
And <setup>
|
||||||
|
When the Search and sharing panel is open
|
||||||
|
Then the screen is "<state>" (<id>) and shows <visible>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| version | setup | state | id | visible |
|
||||||
|
| the French version 12, published | the pattern https://journal.example.com/{lang}/{slug} is set, fr_FR is the social locale, the description is empty with no excerpt or body sentence | Search and sharing: French | loc.seo.noFallback | heading "Search and sharing · French", subtitle "Applies only to the French version. Page language: fr.", the "Canonical" badge with https://journal.example.com/fr/comment-nous-chiffrons-une-renovation and the note "Built from the Public URL pattern https://journal.example.com/{lang}/{slug}. Each language points to itself unless you set a canonical override.", the card "Alternate languages" with "Published versions in enabled languages only.", the rows EN and DE with their URLs and the "Published" badge and the note "Drafts and the disabled Spanish language are left out.", the "Social locale" card with the "og:locale" badge and "Set: fr_FR" and the note "Set by an owner for French in Journal languages. A region is never guessed from the language.", the "Address (slug)" field read-only with the help "Unique within Journal and French. Editing the title never changes the address.", the "Edit slug" button with the notes "Editing the title never changes the slug. Only Edit slug does." and "Links to the old address will stop working. RenewCMS does not create redirects.", the field "Page title" with "Comment nous chiffrons une rénovation \| Journal" and the help "Fallback: French title", the field "Description" empty with the help "No fallback. Empty. English site default not used. The tag is left out." and the field "Image alternative text" with the help "Written per language." |
|
||||||
|
| the French version 12, published | the owner opens Edit slug | Edit slug | loc.slug.published | the dialog "Edit slug for the French version" with "The address changes for French only.", the field "Address (slug)" focused and in monospace, the warning "This version is published" / "Links to the old address will stop working. RenewCMS does not create redirects.", the note "Editing the title never changes the slug.", "Cancel" and the primary "Change address" |
|
||||||
|
| the French version 12 | journal has no pattern | Public URL not configured | loc.url.unset | the info alert "Public URL not configured" / "Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work." with the secondary small button "Open Public URL settings" (to /settings/sites/journal/public-url), and no canonical card, no alternates card and no address |
|
||||||
|
| the French version of a site where fr has no social locale and the tag has no region | the pattern is set | Social locale not set | loc.l10.noSocial | the "Social locale" card with the "og:locale" badge and "Social locale not set" and the note "French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages." |
|
||||||
|
|
||||||
|
Scenario: A draft version has no page address
|
||||||
|
Given the version is a draft
|
||||||
|
Then the canonical card shows the address the page will have and the alternates card lists the other published versions only
|
||||||
|
|
||||||
|
Scenario: A canonical override is shown as the override
|
||||||
|
Given the version has canonical_url "https://other.example/a"
|
||||||
|
Then the canonical card shows "https://other.example/a" and the note says it was set by hand
|
||||||
|
|
||||||
|
Scenario: Fallback labels follow the API
|
||||||
|
Then a field whose fallbacks source is "title" shows "Fallback: <Language> title"
|
||||||
|
And a field whose source is "excerpt" shows "Fallback: <Language> excerpt"
|
||||||
|
And a field whose source is "body" shows "Fallback: <Language> body"
|
||||||
|
And a field with source "none" and site_text_skipped false shows "No fallback"
|
||||||
|
And a field with source "none" and site_text_skipped true shows "No fallback. Empty. <Default language> site default not used. The tag is left out."
|
||||||
|
And a field whose source is "site" shows "Fallback: <Language> site description"
|
||||||
|
|
||||||
|
Scenario Outline: Site text fallback by article language
|
||||||
|
Given the open version is <article> and the site description is "Rénovation et conseils pour la maison" written in the site default language
|
||||||
|
Then the Description field shows <help> and the badge "<badge>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| article | help | badge |
|
||||||
|
| a version in the default language | "Fallback: <Language> site description" and the used text | Uses site text |
|
||||||
|
| a version in another language | "No fallback. Empty. <Default language> site default not used." | Tag left out |
|
||||||
|
|
||||||
|
Scenario: A single-language site shows no language text
|
||||||
|
Given the site enables one language
|
||||||
|
Then the panel keeps its current layout: no "Alternate languages" card, no "Social locale" card and no language in the heading
|
||||||
|
|
||||||
|
Scenario: Editing the slug of a draft needs no warning
|
||||||
|
Given the version is a draft
|
||||||
|
When the owner opens Edit slug
|
||||||
|
Then the dialog has no "This version is published" alert
|
||||||
|
|
||||||
|
Scenario Outline: Slug save outcomes
|
||||||
|
When the owner changes the address and presses "Change address"
|
||||||
|
Then the PATCH /v1/article/12 sends {"slug": "...", "if_revision": <revision>} and the result is <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| response | result |
|
||||||
|
| 200 | the dialog closes, the address and canonical update, a success toast |
|
||||||
|
| 400 invalid_slug | the field shows the existing invalid slug message and the dialog stays |
|
||||||
|
| 409 slug_taken | the field shows the existing slug-taken message and the dialog stays |
|
||||||
|
| 409 revision_conflict | the conflict dialog of features/localization_translation_flows.feature opens |
|
||||||
|
|
||||||
|
Rule: SPA, Integration page (L10 api)
|
||||||
|
|
||||||
|
Scenario: The Integration page documents the language requests
|
||||||
|
Given the route /integration
|
||||||
|
Then it shows the cards "1. Exact lookup: site + language + slug", "2. Resolve by article group, with fallback", "3. Errors" and "Existing, unchanged" with the texts of loc.l10.api
|
||||||
|
And the sample responses use the real shapes of this feature
|
||||||
|
|
||||||
|
Rule: SPA, existing surfaces (L12)
|
||||||
|
|
||||||
|
Scenario Outline: Existing surfaces on a multilingual site
|
||||||
|
Given the site journal as above and <setup>
|
||||||
|
When <trigger>
|
||||||
|
Then the screen is "<state>" (<id>) and shows <visible>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| setup | trigger | state | id | visible |
|
||||||
|
| the reader can read every language | the user opens Home | Home: recent articles | loc.l12.home | recent articles listed once per article (GET /v1/article/?group=story), the title of the default-language version, and a language code chip only when the shown version is not in the default language |
|
||||||
|
| the reader can read only the French versions | the user opens Home | Home: reader of French only | loc.l12.homeFr | the titles in French with the code "FR" |
|
||||||
|
| events carry a lang param | the user opens Activity | Activity | loc.l12.activity | sentences that name the language, for example "Published French version of …" |
|
||||||
|
| an image used by 3 versions of one article | the user opens the usage of the image in Media | Media usage | loc.l12.media | the image counted once for the article (grouped by the story_id of GET /v1/media/{id}/usage), the versions that use it listed with their language (from the lang of each item) |
|
||||||
|
| the open version is French | the user opens the article menu | Article menu labels | loc.l12.menu | "Publish French" / "Unpublish French" and "Delete French version" on a multilingual site |
|
||||||
|
| the open version is French (id 14) | the user opens the editor Details panel | API URL copy | loc.l12.apiCopy | the card "Public API URL" with "Each language version has its own address. The URL carries the language." and the row "/v1/article/14?lang=fr" with a copy icon; copying shows the toast "API URL copied" / "/v1/article/14?lang=fr" |
|
||||||
|
| the open version is French with a pattern set | the user opens the Search preview | SEO preview | loc.l12.seoPreview | the page "Search preview · French version", the card "How it can appear in search" with the breadcrumb address journal.example.com › fr › comment-nous-chiffrons-une-renovation, the title "Comment nous chiffrons une rénovation \| Journal", the description from the excerpt and the note "Shows the localized address and canonical. Description: Fallback: French excerpt." |
|
||||||
|
|
||||||
|
Scenario: Single-language sites show none of it
|
||||||
|
Given the site enables one language
|
||||||
|
Then Home, Activity, Media usage, the article menu, the API URL and the SEO preview look and read as before this slice
|
||||||
|
And Home counts articles, not versions
|
||||||
|
|
||||||
|
Scenario: Home counts articles
|
||||||
|
Given 8 stories with 17 versions
|
||||||
|
Then Home's total and counts are 8 and the recent list never repeats a story
|
||||||
|
|
||||||
|
Scenario: The API URL carries the language only when it is known
|
||||||
|
Given the open version has lang "" (unassigned)
|
||||||
|
Then the copied URL is "/v1/article/<id>" with no lang query
|
||||||
|
|
||||||
|
Scenario: Copying the URL does not change the request contract
|
||||||
|
Then GET /v1/article/14?lang=fr answers the same article as GET /v1/article/14 and an unknown lang value is ignored by that route
|
||||||
|
|
||||||
|
Rule: Real-database lookups
|
||||||
|
|
||||||
|
Scenario: The lookup indexes serve the public queries
|
||||||
|
Then exact lookup by (site_id, lang, slug) and story resolution by (story_id, lang) return the same rows on SQLite, MySQL and PostgreSQL
|
||||||
|
And a soft-deleted version, an offline version and an unassigned article are never returned
|
||||||
|
And the unique indexes idx_articles_site_slug and idx_articles_story_lang exist after Migrate and Migrate again changes nothing
|
||||||
|
|
||||||
|
Rule: Layering and documentation
|
||||||
|
|
||||||
|
Scenario: Layering is respected
|
||||||
|
Then the lookup, the resolution, the alternates, the og:locale derivation and the fallback order live in use cases and domain packages
|
||||||
|
And the domain imports no GORM or HTTP, the use cases import no persistence, mailer, models, gorm, net/http or chi
|
||||||
|
And the two public routes are mounted by api/router.go with no auth middleware and built only in api/dependecyInjection.go
|
||||||
|
|
||||||
|
Scenario: Older features are amended in the same commit
|
||||||
|
Then these older features and their .qa.md are edited, and nothing else:
|
||||||
|
| file | edit |
|
||||||
|
| features/media_library.feature (Rule: Usage) | the example body of "Usage lists the articles whose body contains the url" gains "story_id" and "lang" on each item (assigned articles: the stored story_id and lang) |
|
||||||
|
| features/media_library.feature (Rule: Usage) | a sentence that article items are one per version and carry story_id and lang |
|
||||||
|
| features/media_field_library.feature | line 11: "used_in" counts articles by story (a story once), authors, sites and entries; line 12: article items carry story_id and lang; "The articles heading counts articles only" states articles are grouped by story_id, not by item |
|
||||||
|
| features/media_field_library.feature (delete-in-use scenarios, lines ~234-258) | the article count is the number of distinct stories (plus unassigned articles) |
|
||||||
|
| features/media_browse.feature | the usage mock at line ~214 gains story_id and lang; counts stay by article |
|
||||||
|
| features/seo_data_backend.feature and features/sites_backend.feature | only where they assert the exact key set of an article usage item or used_in of an image shared by versions of one story |
|
||||||
|
| features/seo_output_backend.feature | the exact SEO key set gains lang, alternates, og.locale_alternates, fallbacks.site_text_skipped; the canonical rule and og.locale derivation of assigned articles are replaced; unassigned and pattern-less cases stay |
|
||||||
|
| features/site_languages.feature | "Only the explicit value and the tag's own region resolve the social locale" is replaced by the derived state |
|
||||||
|
| features/integration.feature | the request catalogue lists the two public routes and the new SEO keys |
|
||||||
|
| features/article_localization_storage.feature | none unless the unique indexes are renamed |
|
||||||
|
|
||||||
|
Scenario: The documentation describes the contract
|
||||||
|
Then docs/api/article-localization.md documents both public routes, alternates, requested_lang, fallback, the errors, the SEO keys, the og:locale order, the fallback order and the canonical rule
|
||||||
|
And docs/api.md, docs/api/articles.md and docs/release-notes.md announce the change
|
||||||
|
And the SPA mocks of this slice are written from those pages
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# QA: Localized public API, SEO output and existing surfaces
|
||||||
|
|
||||||
|
Accepted together with features/localization_public_api_and_surfaces.feature. One pair, full path: Go backend first (public routes, SEO, sitemap, languages read, MCP, llms), then the SPA, which mocks `/v1` from `docs/api/article-localization.md`.
|
||||||
|
|
||||||
|
## Backend (Go, `task test`, package `test`)
|
||||||
|
|
||||||
|
Build `NewHandler(...)`s over the real use cases wired to the in-memory fakes already used by `test/article_translation_create_http_test.go`, `test/seo_output_support_test.go` and `test/site_support_test.go` (reuse them, add no second copy; new fake types need unique names). Mount on a fresh `chi.NewRouter()` mirroring `api/router.go`: the two public routes under `/v1/public` with no auth middleware, `/v1/article` with `OptionalAccess`, `/v1/site` for the sitemap and languages. Call `validator.InitValidator()`, exercise with `httptest`. No live server, no SQLite file, no SMTP.
|
||||||
|
|
||||||
|
Fixture: the Background of the feature (journal with en default, fr fr_FR, de, ar, es disabled; story 5 with versions 10, 12, 11, 13, 14; stories 6 and 7; unassigned article 30; atelier with a published fr slug clash; notes without confirmed default). Add pt-BR versions 40 and 41 for the og:locale table.
|
||||||
|
|
||||||
|
1. **Exact lookup, Examples-driven** over the table: status, body code, and for 200 the key set equals the anonymous `GET /v1/article/{id}` key set plus `alternates`. Alternates order (default first, then tag), keys `id, lang, story_id, slug, url`, exclusion of the served version, drafts and disabled languages; `url` is the pattern result, or JSON `null` when the site has no pattern (assert the pattern-less case with the fake).
|
||||||
|
2. **Never falls back and never leaks**: wrong language with right slug, draft with owner cookie (cookie ignored), unpublished after unpublish, old slug after change, unassigned article, soft-deleted version: all `404 article_not_found` with the identical body. A raw-response grep shows the id, slug and title of a hidden sibling appear nowhere.
|
||||||
|
3. **Story resolution, Examples-driven** over the decision table of the design (requested published, missing without fallback, missing with fallback, requested unpublished, both unavailable, default requested and unpublished, no lang, default changed, no confirmed default, disabled language explicit, other site, bad fallback values, bad lang, bad id). Assert `lang`, `requested_lang`, `fallback` on every 200 and that version 11 (de) is never returned as a third language.
|
||||||
|
4. **Default change stability**: after changing the default to fr, lang-omitted resolves to 12 while `?lang=en`, the exact lookup and `GET /v1/article/10` are unchanged.
|
||||||
|
5. **SEO object**: key sets (additive `lang`, `alternates`, `og.locale_alternates`, `fallbacks.site_text_skipped`); canonical table (pattern, override wins, no pattern keeps legacy, unassigned keeps legacy, `{lang}` keeps `pt-BR`); `fallbacks.canonical` values; og:locale Examples table including `en` with Site.Locale `en` and `null` (empty string, alternates `[]`); `og.locale_alternates`; the description/title fallback tables and `site_text_skipped`; the default-change limitation scenario. Existing `test/seo_output_*` specs are amended only where they assert the exact key set, the `og.locale` of an assigned article, or the canonical of a site that has a pattern (unassigned and pattern-less cases must pass unchanged).
|
||||||
|
6. **Sitemap**: eligible versions only, `lang` key, localized urls with a pattern and legacy urls without, `X-Total-Count`, paging invariants unchanged.
|
||||||
|
7. **Languages read**: `social_locale_state` `derived` with `en_GB` for bare `en` and Site.Locale `en-GB`, `not_set` for bare Site.Locale; amend `test/site_languages_*` accordingly.
|
||||||
|
8. **MCP**: `list_articles` forwards `lang` (assert the request URL with a stub HTTP client in `internal/infrastructure/mcpserver`'s existing test style), tool descriptions contain no "cannot be changed".
|
||||||
|
9. **Catalogue drift**: `test/api_catalogue_drift_test.go` green with the two new routes; `web/src/data/apiCatalogue.ts` counts updated.
|
||||||
|
10. **Layering**: existing layering tests stay green; the public handler imports no persistence, models or gorm.
|
||||||
|
11. **Media usage** (extend `TestMediaUsage` in `test/media_library_http_test.go` and the usage cases of `test/media_merge_hardening_test.go` and `test/seo_data_media_http_test.go` rather than a parallel file; the fakes and `NewGetMediaUsageUseCase(...)` wiring already exist): fixture image 7 as in the feature's Rule "Media usage names the language and counts a story once". Assert: one article item per version, ordered by id (10, 11, 12, 20, 30); every article item carries `story_id` and `lang` (item 12 equals the exact JSON of the feature, key set `type,id,title,published,fields,story_id,lang`); the unassigned article 30 has `lang` `""` and is not grouped with any story; author, site and entry items have neither key (assert absence in the raw JSON); order articles, authors, sites, entries unchanged; unused image answers `[]`; `404 Image not found` and `400 apperr.IdUint32ErrorMessage` unchanged. Grouping check: items 10, 11, 12 share `story_id` 5. `GET /v1/media/` gives `used_in` 5 for image 7 (story 5 once, story 6, article 30, author, site) and the same count as before the slice on a one-language fixture. The delete-in-use count (unchanged endpoint, SPA side) is covered in the SPA list below. Amend only the usage assertions that compare an article item's exact JSON or `used_in` of an image shared by several versions of a story; every other media test must pass untouched.
|
||||||
|
|
||||||
|
Older features amended in the same commit (listed in the feature's scenario "Older features are amended in the same commit"): `media_library.feature` (Rule: Usage), `media_field_library.feature` (lines 11-12, articles heading, delete-in-use counts), `media_browse.feature` (usage mock), `seo_data_backend.feature` and `sites_backend.feature` (only exact usage key sets / `used_in`), `seo_output_backend.feature`, `site_languages.feature`, `integration.feature`; their `.qa.md` files follow.
|
||||||
|
|
||||||
|
Real-database runs are required (gate): `task test:mysql` and `task test:postgres` for a new persistence test in `test/article_localization_storage_persistence_test.go` (or a sibling file) that exercises the repository queries behind exact lookup and story resolution: same results on each driver, soft-deleted and offline rows excluded, unassigned excluded, `EXPLAIN`-free (assert correctness and the presence of `idx_articles_site_slug` and `idx_articles_story_lang`, not the plan). Run SQLite in `task test`. Run both real-DB tasks once, at the end of the backend stage. Use the podman shim if the Docker daemon is off.
|
||||||
|
|
||||||
|
Docs (the contract the SPA mocks): `docs/api/article-localization.md`, `docs/api.md`, `docs/api/articles.md`, `docs/llms.md`, `docs/llm/` recipes, `docs/release-notes.md`.
|
||||||
|
|
||||||
|
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
|
Mock `useApi`'s `request` per case (no backend), from the documented contract. Mount EditorView in DashboardShell with a verified session (user id 7, username "marta") and reuse `editorKit.ts` and `localizationKit.ts`. Fixture: the Background (journal, story 5, version 12 open). Specs next to the code in `__tests__/`; extend the existing `EditorSeo*.spec.ts` files rather than adding a parallel set.
|
||||||
|
|
||||||
|
1. **Search and sharing, one test per state**: loc.seo.noFallback (every heading, note, badge, row and help text listed in the feature), loc.slug.published, loc.url.unset (alert, button target `/settings/sites/journal/public-url`, no canonical, no alternates), loc.l10.noSocial.
|
||||||
|
2. **Fallback labels, Examples-driven** over every `fallbacks` source and `site_text_skipped` value; both site-text articles (default language, other language).
|
||||||
|
3. **Draft, canonical override, single-language site**: alternates and drafts, override note, no language text on a one-language site.
|
||||||
|
4. **Edit slug**: warning only when published; request body `{slug, if_revision}`; 400 `invalid_slug`, 409 slug conflict and 409 `revision_conflict` outcomes; focus returns to the Edit slug button.
|
||||||
|
5. **Integration page**: the four cards and sample shapes; catalogue entries for both routes; no "Titles can't be changed" sentence.
|
||||||
|
6. **Home**: request uses `group=story`; default-language title; the code chip only for non-default; French-only reader (loc.l12.homeFr); counts are articles; single-language site unchanged.
|
||||||
|
7. **Activity**: sentences with the language for every article action code that carries `params.lang` (publish, unpublish, create, update, delete), English and French; events without `lang` unchanged.
|
||||||
|
8. **Media usage**: mock `GET /v1/media/7/usage` with items 10, 11, 12 (story_id 5; langs en, de, fr), 20 (story 6) and 30 (lang ""): the Articles section shows story 5 once with the versions EN, DE, FR listed and the heading counts articles (3 with story 6 and article 30), not 5 items; a one-language site lists as before; the delete-in-use dialog counts stories; items without `story_id`/`lang` (older mocks) still render one row per item (existing `mediaUsage` specs stay green).
|
||||||
|
9. **Article menu labels**: Publish, Unpublish and Delete name the language on multilingual sites only.
|
||||||
|
10. **API URL copy**: the Details card text, copied text `/v1/article/14?lang=fr`, toast "API URL copied", unassigned version copies without `lang`, clipboard failure handling as today.
|
||||||
|
11. **SEO preview**: breadcrumb, title, description fallback line, the note.
|
||||||
|
12. **Keyboard and focus**: Edit slug dialog trap and Escape, row menu items unchanged.
|
||||||
|
|
||||||
|
Existing SPA specs to amend (and nothing else): `EditorSeoPanel`, `EditorSeoSlug`, `EditorSeoFrench`, `SeoIntegration`, `IntegrationView`, `HomeView`, `ActivityView`, media usage specs, `seoApiPreview.spec.ts`, `apiCatalogue` spec.
|
||||||
|
|
||||||
|
## Visual (`task visual:check -- <screen>`)
|
||||||
|
|
||||||
|
Add setups in `web/visual/states.ts` with the board's own fixture data (Journal, locale-fixtures.js; the French title "Comment nous chiffrons une rénovation", slugs and URLs of the board). Screens and states, light and dark, 1440 and 390 (and 1024 where the table says so):
|
||||||
|
|
||||||
|
| Screen file | States |
|
||||||
|
| --- | --- |
|
||||||
|
| localization-metadata-api | loc.seo.noFallback, loc.slug.published, loc.url.unset, loc.l10.noSocial, loc.l10.api (see open question below) |
|
||||||
|
| localization-surfaces | loc.l12.home, loc.l12.homeFr, loc.l12.activity, loc.l12.media (1440, 1024, 390), loc.l12.menu, loc.l12.apiCopy, loc.l12.seoPreview, loc.l12.siteFallback (1440, 390) |
|
||||||
|
|
||||||
|
Clock-sensitive: loc.l12.activity and loc.l12.home show relative times if the SPA renders them from the current date; if so mark `skip: 'clock-sensitive'` and cover the language text in Vitest with a fixed event list. No date freezing, no threshold change, no exception without the user's explicit approval.
|
||||||
|
|
||||||
|
Decision D6 (user): the specification works around these design conflicts. For loc.l10.api, loc.l12.siteFallback, loc.l12.apiCopy and loc.l12.seoPreview the coder must stop and return the measured differences or route gaps to the user; it must not edit docs/design, add a `skip`, change a threshold or add an exception. The route `language_disabled` is not returned by any public route (a disabled language stays readable). Known gaps (reported at specification time, not waived): loc.l10.api is drawn with the alert "Proposed capability / These requests don't exist yet" and the story id "st_01", which will be false once this slice ships; loc.l12.siteFallback draws two different articles in one frame (French and Arabic), which no single SPA screen can show; loc.l12.apiCopy and loc.l12.seoPreview are drawn as stand-alone "Details" and "Search preview" pages whose route is not named in docs/design/routes.md.
|
||||||
|
|
||||||
|
## Manual (not covered by screenshots or Vitest)
|
||||||
|
|
||||||
|
1. Keyboard and focus order in the Edit slug dialog and the new alternates and social locale cards; hover and motion.
|
||||||
|
2. Real browser: copy button writes to the real clipboard.
|
||||||
|
3. Real embedded frontend build (`task build`, run the binary): `/integration` shows the new cards; no console errors.
|
||||||
|
4. Real SMTP is not involved.
|
||||||
|
5. A real site with a Public URL pattern: open the canonical and an alternate URL in a browser is not possible (they point to the website, not RenewCMS); verify instead that the strings match the pattern.
|
||||||
@@ -211,7 +211,7 @@ Feature: Media page, part 1: browse and manage
|
|||||||
Then the card's selection toggles and no panel opens
|
Then the card's selection toggles and no panel opens
|
||||||
|
|
||||||
Scenario: Detail panel, in use
|
Scenario: Detail panel, in use
|
||||||
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true }, { id: 11, title: "Winter checklist for timber windows", published: false }]
|
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true, story_id: 5, lang: "en" }, { id: 11, title: "Winter checklist for timber windows", published: false, story_id: 6, lang: "en" }]
|
||||||
When the user activates the "north-wall.jpg" card
|
When the user activates the "north-wall.jpg" card
|
||||||
Then the slideover opens with the title "north-wall.jpg", a 16:10 preview and the actions "Copy URL", "Rename" and "Delete" (Delete in error-coloured text)
|
Then the slideover opens with the title "north-wall.jpg", a 16:10 preview and the actions "Copy URL", "Rename" and "Delete" (Delete in error-coloured text)
|
||||||
And the metadata list reads Type "JPEG", Size "3.9 MB", Dimensions "4032 × 3024", Uploaded "12 Sep 2026", Uploaded by "Marta Alves" and URL in mono
|
And the metadata list reads Type "JPEG", Size "3.9 MB", Dimensions "4032 × 3024", Uploaded "12 Sep 2026", Uploaded by "Marta Alves" and URL in mono
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Toast ids: "media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}",
|
|||||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
Mock `useApi`'s `request` (or the global fetch it wraps) per case for `GET /v1/media/`,
|
Mock `useApi`'s `request` (or the global fetch it wraps) per case for `GET /v1/media/`,
|
||||||
`GET /v1/media/{id}/usage`, `GET /v1/media/?hash=`, `PATCH /v1/media/{id}` and
|
`GET /v1/media/{id}/usage` (article items carry `story_id` and `lang`; counts stay by article), `GET /v1/media/?hash=`, `PATCH /v1/media/{id}` and
|
||||||
`DELETE /v1/media/{id}`, plus `navigator.clipboard.writeText` and `navigator.onLine`. No real
|
`DELETE /v1/media/{id}`, plus `navigator.clipboard.writeText` and `navigator.onLine`. No real
|
||||||
backend. Mount MediaPage inside DashboardShell with a verified session and a test router at
|
backend. Mount MediaPage inside DashboardShell with a verified session and a test router at
|
||||||
/media. Sample data is the 12-image fixture in the .feature Background (same ids, filenames,
|
/media. Sample data is the 12-image fixture in the .feature Background (same ids, filenames,
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ Feature: Media field usage in the Media library
|
|||||||
And API errors are plain text
|
And API errors are plain text
|
||||||
And this slice changes only web/ (and web/visual); the backend of features/media_field_type.feature is done and not touched
|
And this slice changes only web/ (and web/visual); the backend of features/media_field_type.feature is done and not touched
|
||||||
And the design is docs/design/media-field.md section 3.8 and section 4, with the board group "11 Media library: usage" (4 states) of "RenewCMS Screens Schema.dc.html", which draws MediaFrame.dc.html with entries counted as uses
|
And the design is docs/design/media-field.md section 3.8 and section 4, with the board group "11 Media library: usage" (4 states) of "RenewCMS Screens Schema.dc.html", which draws MediaFrame.dc.html with entries counted as uses
|
||||||
And GET /v1/media/ items carry "used_in", which counts articles, authors, sites and entries together, and no split
|
And GET /v1/media/ items carry "used_in", which counts articles by story (a story once), authors, sites and entries together, and no split
|
||||||
And GET /v1/media/{id}/usage answers an array ordered articles, authors, sites, then entries by content type name then entry id, with no total and no "first few" wrapper
|
And GET /v1/media/{id}/usage answers an array ordered articles, authors, sites, then entries by content type name then entry id, with no total and no "first few" wrapper
|
||||||
And an article item is {"type":"article","id","title","published","fields"}
|
And an article item is {"type":"article","id","title","published","fields","story_id","lang"}, one per version, and the SPA groups article items by story_id
|
||||||
And an entry item is {"type":"entry","id","fields","content_type_key","content_type_name","first_field":{"key","type","value"}} with no title and no published, and first_field absent when the type has no first field
|
And an entry item is {"type":"entry","id","fields","content_type_key","content_type_name","first_field":{"key","type","value"}} with no title and no published, and first_field absent when the type has no first field
|
||||||
And the SPA keeps the first 3 entry items, counts the rest, and derives the entry title from first_field with the title rules of the entry list (see "Entry title in the usage lists")
|
And the SPA keeps the first 3 entry items, counts the rest, and derives the entry title from first_field with the title rules of the entry list (see "Entry title in the usage lists")
|
||||||
And deleting a used image stays allowed; entries keep the id and show "This image is no longer in the library"
|
And deleting a used image stays allowed; entries keep the id and show "This image is no longer in the library"
|
||||||
@@ -152,6 +152,7 @@ Feature: Media field usage in the Media library
|
|||||||
Scenario: The articles heading counts articles only
|
Scenario: The articles heading counts articles only
|
||||||
Given lime-plaster.jpg has used_in 7 (2 articles, 5 entries)
|
Given lime-plaster.jpg has used_in 7 (2 articles, 5 entries)
|
||||||
Then the heading reads "Used in 2 articles" and never "Used in 7 articles"
|
Then the heading reads "Used in 2 articles" and never "Used in 7 articles"
|
||||||
|
And articles are grouped by story_id, not by item, so several versions of one story count as one article
|
||||||
|
|
||||||
Scenario: Entry link keyboard and focus
|
Scenario: Entry link keyboard and focus
|
||||||
Given the panel of lime-plaster.jpg is open
|
Given the panel of lime-plaster.jpg is open
|
||||||
@@ -231,7 +232,7 @@ Feature: Media field usage in the Media library
|
|||||||
And the entry rows show the type name and the title and are not links in the dialog (decision D10)
|
And the entry rows show the type name and the title and are not links in the dialog (decision D10)
|
||||||
|
|
||||||
Scenario Outline: The wording by what uses the image
|
Scenario Outline: The wording by what uses the image
|
||||||
Given an image "lime-plaster.jpg" with <articles> articles and <entries> entries in its loaded usage
|
Given an image "lime-plaster.jpg" with <articles> articles (the number of distinct stories, plus unassigned articles) and <entries> entries in its loaded usage
|
||||||
Then the title is "<title>"
|
Then the title is "<title>"
|
||||||
And the description reads <description>
|
And the description reads <description>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Decisions accepted by the user (all as the feature draft assumes):
|
|||||||
- D4 truncation: one line, ellipsis, native title.
|
- D4 truncation: one line, ellipsis, native title.
|
||||||
- D5 usage failure: inline message with "Try again" in the panel, silent tooltip.
|
- D5 usage failure: inline message with "Try again" in the panel, silent tooltip.
|
||||||
- D6 usage loading: skeleton after 150 ms, delete dialog opens at once with a generic description.
|
- D6 usage loading: skeleton after 150 ms, delete dialog opens at once with a generic description.
|
||||||
- D7 authors and sites in the usage: filtered out, heading counts articles only.
|
- D7 authors and sites in the usage: filtered out, heading counts articles only. Article items carry `story_id` and `lang`; articles are grouped by `story_id` (a story counts once, in the heading, the panel and the delete dialog); items without `story_id` still render one row each. See features/localization_public_api_and_surfaces.qa.md, item 8 of the SPA list.
|
||||||
- D8 bulk delete: "N places".
|
- D8 bulk delete: "N places".
|
||||||
- D9 merge: "N places".
|
- D9 merge: "N places".
|
||||||
- D10 entry rows in the delete dialog are not links.
|
- D10 entry rows in the delete dialog are not links.
|
||||||
|
|||||||
@@ -289,7 +289,8 @@ Feature: The media library holds every uploaded image as a library-level asset
|
|||||||
Given article 1 "Kitchen Renovation" is online and article 2 "Backyard Notes" is a draft, both containing image 1's url, and article 3 does not
|
Given article 1 "Kitchen Renovation" is online and article 2 "Backyard Notes" is a draft, both containing image 1's url, and article 3 does not
|
||||||
When the client sends GET /v1/media/1/usage with a valid cookie
|
When the client sends GET /v1/media/1/usage with a valid cookie
|
||||||
Then the response status is 200
|
Then the response status is 200
|
||||||
And the body is [{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}]
|
And the body is [{"id": 1, "title": "Kitchen Renovation", "published": true, "story_id": 1, "lang": "en"}, {"id": 2, "title": "Backyard Notes", "published": false, "story_id": 2, "lang": "en"}]
|
||||||
|
And the items are one per version, and each carries the stored story_id and lang of its article (lang "" for an unassigned article), as in features/localization_public_api_and_surfaces.feature
|
||||||
|
|
||||||
Scenario: An unused image has an empty array, not null
|
Scenario: An unused image has an empty array, not null
|
||||||
When the client sends GET /v1/media/4/usage
|
When the client sends GET /v1/media/4/usage
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake ty
|
|||||||
2. Rewrite `mockImageRepo` (`test/image_test.go`) to the new `image.Repository`: Create (stores the metadata from the ImageInput and derives a `/static/uploadedImages/<n>.<ext>` path), Get, Delete (records calls, honours `failDeleteFor`), List(query), FindByHash, Rename, ListByHash groups, Counts. Add fields for seeding `Filename`, `Size`, `ContentType`, `Width`, `Height`, `SHA256`, `UploadedByID`, `CreatedAt`.
|
2. Rewrite `mockImageRepo` (`test/image_test.go`) to the new `image.Repository`: Create (stores the metadata from the ImageInput and derives a `/static/uploadedImages/<n>.<ext>` path), Get, Delete (records calls, honours `failDeleteFor`), List(query), FindByHash, Rename, ListByHash groups, Counts. Add fields for seeding `Filename`, `Size`, `ContentType`, `Width`, `Height`, `SHA256`, `UploadedByID`, `CreatedAt`.
|
||||||
3. Extend `mockArticleRepo` (`test/article_test.go`) with `FindByBodyContaining(filename)` (substring over `Body`) and a per-id failure switch for `UpdateBody`. Remove `AddImage` and the article `Images` field from the fake and from every test that uses them.
|
3. Extend `mockArticleRepo` (`test/article_test.go`) with `FindByBodyContaining(filename)` (substring over `Body`) and a per-id failure switch for `UpdateBody`. Remove `AddImage` and the article `Images` field from the fake and from every test that uses them.
|
||||||
4. Use the existing user fake so `uploaded_by` resolves: user 1 display name "Inès Duarte", user 2 username "marta" with empty display name.
|
4. Use the existing user fake so `uploaded_by` resolves: user 1 display name "Inès Duarte", user 2 username "marta" with empty display name.
|
||||||
5. Wire the real use cases over the fakes: create, list, rename, delete, merge and usage media use cases, `NewCreateImageUseCase(imageRepo)`, `NewGetUserUseCase(userRepo)`. Build `media.NewHandler(...)` (`api/handlers/media`).
|
5. Wire the real use cases over the fakes: create, list, rename, delete, merge and usage media use cases, `NewCreateImageUseCase(imageRepo)`, `NewGetUserUseCase(userRepo)`. Build `media.NewHandler(...)` (`api/handlers/media`). Usage article items are one per version and carry `story_id` and `lang` (see features/localization_public_api_and_surfaces.qa.md, item 11); the usage example of the feature is asserted with those keys.
|
||||||
6. Build `jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount on a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier` at the top, then in the media group `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`, with the six routes under `/v1/media`.
|
6. Build `jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount on a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier` at the top, then in the media group `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`, with the six routes under `/v1/media`.
|
||||||
7. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), ...})` as in the other HTTP tests. Build a stale-token cookie with a `token_version` lower than the user's, and use an unverified user for the 403 case.
|
7. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), ...})` as in the other HTTP tests. Build a stale-token cookie with a `token_version` lower than the user's, and use an unverified user for the 403 case.
|
||||||
8. Build multipart bodies with `mime/multipart.Writer`, using `CreatePart` with an explicit part `Content-Type` header. Generate real bytes in the test:
|
8. Build multipart bodies with `mime/multipart.Writer`, using `CreatePart` with an explicit part `Content-Type` header. Generate real bytes in the test:
|
||||||
|
|||||||
@@ -602,7 +602,7 @@ Feature: SEO data backend, article and site search fields, unique slugs and medi
|
|||||||
Given the image 5 is in the body of the article 3, the cover of the article 8, the avatar of the author 2 and the default social image of the site 1
|
Given the image 5 is in the body of the article 3, the cover of the article 8, the avatar of the author 2 and the default social image of the site 1
|
||||||
When a verified account sends GET /v1/media/5/usage
|
When a verified account sends GET /v1/media/5/usage
|
||||||
Then the status is 200
|
Then the status is 200
|
||||||
And the body is a JSON array of 4 items: {"type": "article", "id": 3, "title": "...", "published": ..., "fields": ["body"]}, {"type": "article", "id": 8, "title": "...", "published": ..., "fields": ["cover"]}, {"type": "author", "id": 2, "title": "<author name>", "fields": ["avatar"]} and {"type": "site", "id": 1, "title": "<site name>", "fields": ["default_social_image"]}
|
And the body is a JSON array of 4 items: {"type": "article", "id": 3, "title": "...", "published": ..., "fields": ["body"], "story_id": ..., "lang": "..."}, {"type": "article", "id": 8, "title": "...", "published": ..., "fields": ["cover"], "story_id": ..., "lang": "..."},{"type": "author", "id": 2, "title": "<author name>", "fields": ["avatar"]} and {"type": "site", "id": 1, "title": "<site name>", "fields": ["default_social_image"]}
|
||||||
And author and site items carry no published key
|
And author and site items carry no published key
|
||||||
|
|
||||||
Scenario: The change is additive for existing consumers
|
Scenario: The change is additive for existing consumers
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `t
|
|||||||
|
|
||||||
## F. Media usage
|
## F. Media usage
|
||||||
|
|
||||||
27. Usage lookup: the item table for body, cover, social, avatar and site image, each with its `type`, `id`, `title` and `fields`; one item per article with merged `fields`; `published` present for articles (true or false) and absent for authors and sites; the article item keeps `id`, `title` and `published` with their previous values and the only new keys are `type` and `fields` (additive check); empty array when unused; unknown image gives the existing 404.
|
27. Usage lookup: the item table for body, cover, social, avatar and site image, each with its `type`, `id`, `title` and `fields`; one item per article with merged `fields`; `published` present for articles (true or false) and absent for authors and sites; the article item keeps `id`, `title` and `published` with their previous values and the only new keys are `type`, `fields`, and for articles `story_id` and `lang` (additive check); empty array when unused; unknown image gives the existing 404.
|
||||||
28. `used_in` and the `unused` filter count cover, social, site and avatar once per article, author and site; an article using the image in body and cover counts once.
|
28. `used_in` and the `unused` filter count cover, social, site and avatar once per article, author and site; an article using the image in body and cover counts once.
|
||||||
29. Delete: never refused; references are nulled, `cover_image_alt` and article bodies are untouched, avatars and sites cleared as before, and a failed removal rolls back every reference.
|
29. Delete: never refused; references are nulled, `cover_image_alt` and article bodies are untouched, avatars and sites cleared as before, and a failed removal rolls back every reference.
|
||||||
30. Merge: articles' cover and social ids repointed; `articles_updated` counts each article once even if body and cover both changed; `updated_article_ids` lists them; `authors_updated` and `sites_updated` still correct. Purge does the same, a failed group leaves every reference on an existing image, and the preview treats cover and social as usage.
|
30. Merge: articles' cover and social ids repointed; `articles_updated` counts each article once even if body and cover both changed; `updated_article_ids` lists them; `authors_updated` and `sites_updated` still correct. Purge does the same, a failed group leaves every reference on an existing image, and the preview treats cover and social as usage.
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list sta
|
|||||||
Scenario: The object has the documented shape
|
Scenario: The object has the documented shape
|
||||||
When an anonymous caller sends GET /v1/article/12/seo
|
When an anonymous caller sends GET /v1/article/12/seo
|
||||||
Then the status is 200
|
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 the body has exactly the keys title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld, lang and alternates
|
||||||
And og has exactly the keys type, title, description, image, image_alt, url, site_name and locale
|
And og has exactly the keys type, title, description, image, image_alt, url, site_name, locale and locale_alternates
|
||||||
And twitter has exactly the keys card, title, description, image and image_alt
|
And twitter has exactly the keys card, title, description, image and image_alt
|
||||||
And image has exactly the keys url, alt and source
|
And image has exactly the keys url, alt and source
|
||||||
And fallbacks has exactly the keys title, description, image and canonical
|
And fallbacks has exactly the keys title, description, image, canonical and site_text_skipped
|
||||||
And the body has no body, no id and no seo_dismissed key
|
And the body has no body, no id and no seo_dismissed key
|
||||||
|
|
||||||
Scenario: A bare article resolves every output through the fallbacks
|
Scenario: A bare article resolves every output through the fallbacks
|
||||||
@@ -43,9 +43,10 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list sta
|
|||||||
And canonical_url is "https://www.renew.example/how-we-price-a-bathroom-renovation"
|
And canonical_url is "https://www.renew.example/how-we-price-a-bathroom-renovation"
|
||||||
And robots is "index, follow"
|
And robots is "index, follow"
|
||||||
And image is {"url": "https://www.renew.example/static/uploadedImages/7.png", "alt": "", "source": "site"}
|
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 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", "locale_alternates": []}
|
||||||
|
And lang is "" and alternates is [] because this article is unassigned, so the legacy canonical and the raw Site.Locale apply
|
||||||
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 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 fallbacks is {"title": "title", "description": "body", "image": "site", "canonical": "site", "site_text_skipped": false}
|
||||||
And published_at is "2026-09-01T08:00:00Z" and modified_at is "2026-09-10T09:30:00Z"
|
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
|
Scenario Outline: The title chain and the template
|
||||||
@@ -118,8 +119,10 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list sta
|
|||||||
And fallbacks.canonical is "<source>"
|
And fallbacks.canonical is "<source>"
|
||||||
And robots is "<result>"
|
And robots is "<result>"
|
||||||
|
|
||||||
|
And for an assigned article the canonical is built from the Public URL pattern with {lang} and {slug} and fallbacks.canonical is "pattern", while this table (unassigned article, or a site with no pattern) keeps the legacy base_url rule, and og:locale derivation follows features/localization_public_api_and_surfaces.feature
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
| canonical | noindex | robots | resolved | source | result |
|
| canonical | noindex | robots | resolved | source | result |
|
||||||
| | false | index | https://www.renew.example/how-we-price-a-bathroom-renovation | site | index, follow |
|
| | false | index | https://www.renew.example/how-we-price-a-bathroom-renovation | site | index, follow |
|
||||||
| | false | noindex | https://www.renew.example/how-we-price-a-bathroom-renovation | site | noindex, nofollow |
|
| | false | noindex | https://www.renew.example/how-we-price-a-bathroom-renovation | site | noindex, nofollow |
|
||||||
| https://other.example/a | false | index | https://other.example/a | canonical_url | index, follow |
|
| https://other.example/a | false | index | https://other.example/a | canonical_url | index, follow |
|
||||||
@@ -574,7 +577,8 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list sta
|
|||||||
Then the status is 200
|
Then the status is 200
|
||||||
And the body is a JSON array of 2 items: {"url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "modified_at": "2026-09-10T09:30:00Z", "noindex": false} and the item of the article 16
|
And the body is a JSON array of 2 items: {"url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "modified_at": "2026-09-10T09:30:00Z", "noindex": false} and the item of the article 16
|
||||||
And the header X-Total-Count is "2"
|
And the header X-Total-Count is "2"
|
||||||
And each item has exactly the keys url, modified_at and noindex
|
And each item has exactly the keys url, modified_at, noindex and lang
|
||||||
|
And only published versions in enabled languages are listed, each at its localized address when the site has a Public URL pattern
|
||||||
|
|
||||||
Scenario: Noindex articles are included with the flag
|
Scenario: Noindex articles are included with the flag
|
||||||
Given the online article 16 has noindex true
|
Given the online article 16 has noindex true
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Spec: `features/seo_output_backend.feature`. Go tests only, in `test/` (package
|
|||||||
|
|
||||||
## C. Check endpoint
|
## C. Check endpoint
|
||||||
|
|
||||||
10. Shape: exact keys of root, suggestion, `slug`.
|
10. Shape: exact keys of root (now with `lang` and `alternates`), `og` (with `locale_alternates`), `fallbacks` (with `site_text_skipped`), sitemap items (with `lang`), suggestion, `slug`. The canonical of an assigned article on a site with a pattern and its `og.locale` follow features/localization_public_api_and_surfaces.qa.md, item 5; unassigned and pattern-less cases pass unchanged.
|
||||||
11. Code table: one article per code with the exact `field` and `params` and the thresholds above.
|
11. Code table: one article per code with the exact `field` and `params` and the thresholds above.
|
||||||
12. Edge tables: title 60/61, description 70/69 and 160/161, slug 75/76, `no_h` 999/1000 with h2, h6, h1 only, `short` 299/300, rune counting with 2-byte characters, title measured before the template, text length ignoring markup.
|
12. Edge tables: title 60/61, description 70/69 and 160/161, slug 75/76, `no_h` 999/1000 with h2, h6, h1 only, `short` 299/300, rune counting with 2-byte characters, title measured before the template, text length ignoring markup.
|
||||||
13. Fully prepared article gives `ready` and `[]`; overrides replace saved values (set, clear, null image id, body); the saved article, its `updated_at`, the site, the media usage and the activity log are unchanged after every call.
|
13. Fully prepared article gives `ready` and `[]`; overrides replace saved values (set, clear, null image id, body); the saved article, its `updated_at`, the site, the media usage and the activity log are unchanged after every call.
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ Feature: Site languages (API and Languages page)
|
|||||||
| fr | set | fr_FR | fr_FR |
|
| fr | set | fr_FR | fr_FR |
|
||||||
| en | not_set | null | null |
|
| en | not_set | null | null |
|
||||||
|
|
||||||
Scenario: Only the explicit value and the tag's own region resolve the social locale
|
Scenario: A bare tag borrows the region of Site.Locale
|
||||||
Given site "atelier" has Locale "en-GB" and a language "en" with social_locale null
|
Given site "atelier" has Locale "en-GB" and a language "en" with social_locale null
|
||||||
When an owner reads its languages
|
When an owner reads its languages
|
||||||
Then language "en" has social_locale_state "not_set"
|
Then language "en" has social_locale_state "derived" and an effective value "en_GB"
|
||||||
And matching the Site.Locale language part is deferred to slice 6
|
And a bare Site.Locale such as "en" leaves the state "not_set", as in features/localization_public_api_and_surfaces.feature
|
||||||
|
|
||||||
Scenario: A site that still needs setup
|
Scenario: A site that still needs setup
|
||||||
Given site "notes" has no confirmed default language
|
Given site "notes" has no confirmed default language
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Mock /v1 per case. Do not freeze the system date.
|
|||||||
- Row menu: Make default, Disable, Remove.
|
- Row menu: Make default, Disable, Remove.
|
||||||
- Disabled language in the picker: "Disabled — re-enable instead".
|
- Disabled language in the picker: "Disabled — re-enable instead".
|
||||||
- Version counts are visible to every signed-in reader; the exception is recorded in docs/.
|
- Version counts are visible to every signed-in reader; the exception is recorded in docs/.
|
||||||
- Social locale rule 3 (Site.Locale language part) is deferred to slice 6.
|
- Social locale rule 3 (Site.Locale language part) is the `derived` state, covered by features/localization_public_api_and_surfaces.qa.md, item 7.
|
||||||
|
|
||||||
## Fix round additions
|
## Fix round additions
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ Feature: Public URL pattern of a site
|
|||||||
|
|
||||||
Scenario: Saving the pattern changes nothing else
|
Scenario: Saving the pattern changes nothing else
|
||||||
When an owner sets the pattern
|
When an owner sets the pattern
|
||||||
Then no article, canonical URL or public response changes
|
Then no article is changed and no canonical override is changed
|
||||||
|
And the canonical output and sitemap addresses of the site's articles follow the pattern from then on, as in features/localization_public_api_and_surfaces.feature
|
||||||
And the site's other fields are unchanged
|
And the site's other fields are unchanged
|
||||||
|
|
||||||
Scenario: Existing site behaviour is unchanged
|
Scenario: Existing site behaviour is unchanged
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Accepted together with features/site_public_url_pattern.feature.
|
|||||||
|
|
||||||
## Automated, SQLite file (task test)
|
## Automated, SQLite file (task test)
|
||||||
|
|
||||||
|
3b. Saving the pattern changes no article and no canonical override; the canonical output and sitemap addresses follow the pattern afterwards (features/localization_public_api_and_surfaces.qa.md, items 5 and 6).
|
||||||
4. The column migration is covered in the site_languages QA step 5. Round-trip a pattern of 512 characters and refuse 513.
|
4. The column migration is covered in the site_languages QA step 5. Round-trip a pattern of 512 characters and refuse 513.
|
||||||
|
|
||||||
## Automated (Vitest + Vue Test Utils, task test:web)
|
## Automated (Vitest + Vue Test Utils, task test:web)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Spec: `features/sites_backend.feature`. Go tests only, in `test/` (package `test
|
|||||||
|
|
||||||
## H. Media
|
## H. Media
|
||||||
|
|
||||||
38. Usage lookup counts a site's default social image, stays a JSON array whose article items keep id, title and published (additive keys are specified in seo_data_backend), image not "Unused".
|
38. Usage lookup counts a site's default social image, stays a JSON array whose article items keep id, title and published (additive keys, including story_id and lang, are specified in seo_data_backend), image not "Unused".
|
||||||
39. Deleting the image succeeds (204) and nulls the site's `default_social_image_id`.
|
39. Deleting the image succeeds (204) and nulls the site's `default_social_image_id`.
|
||||||
40. Merge repoints sites and reports `sites_updated`; purge does the same; a failed purge group leaves every site pointing at an existing image.
|
40. Merge repoints sites and reports `sites_updated`; purge does the same; a failed purge group leaves every site pointing at an existing image.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrLanguageNotAvailable = errors.New("no published version in that language")
|
||||||
|
|
||||||
|
type Alternate struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
StoryID uint32 `json:"story_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ type Filters struct {
|
|||||||
Q string
|
Q string
|
||||||
Status string
|
Status string
|
||||||
Lang *string
|
Lang *string
|
||||||
|
Langs *[]string
|
||||||
AuthorID *uint32
|
AuthorID *uint32
|
||||||
Sort Sort
|
Sort Sort
|
||||||
Unpaged bool
|
Unpaged bool
|
||||||
@@ -101,6 +102,7 @@ type Repository interface {
|
|||||||
ListByStories(storyIDs []uint32) ([]Article, error)
|
ListByStories(storyIDs []uint32) ([]Article, error)
|
||||||
Get(id uint32) (Article, error)
|
Get(id uint32) (Article, error)
|
||||||
GetByName(name string) (Article, error)
|
GetByName(name string) (Article, error)
|
||||||
|
GetByLangSlug(siteID uint32, lang string, slug string) (Article, error)
|
||||||
GetAll() ([]Article, error)
|
GetAll() ([]Article, error)
|
||||||
ListForAccount(creatorID uint32, authorID *uint32) ([]Article, error)
|
ListForAccount(creatorID uint32, authorID *uint32) ([]Article, error)
|
||||||
Create(post Article) (Article, error)
|
Create(post Article) (Article, error)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const (
|
|||||||
SEOSourceSocial = "social"
|
SEOSourceSocial = "social"
|
||||||
SEOSourceCover = "cover"
|
SEOSourceCover = "cover"
|
||||||
SEOSourceCanonicalURL = "canonical_url"
|
SEOSourceCanonicalURL = "canonical_url"
|
||||||
|
SEOSourcePattern = "pattern"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SEOOpenGraph struct {
|
type SEOOpenGraph struct {
|
||||||
@@ -22,6 +23,8 @@ type SEOOpenGraph struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
|
|
||||||
|
LocaleAlternates []string `json:"locale_alternates"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SEOTwitter struct {
|
type SEOTwitter struct {
|
||||||
@@ -49,6 +52,8 @@ type SEOFallbacks struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Canonical string `json:"canonical"`
|
Canonical string `json:"canonical"`
|
||||||
|
|
||||||
|
SiteTextSkipped bool `json:"site_text_skipped"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SEOJSONLDPerson struct {
|
type SEOJSONLDPerson struct {
|
||||||
@@ -88,4 +93,6 @@ type ResolvedSEO struct {
|
|||||||
ModifiedAt string `json:"modified_at"`
|
ModifiedAt string `json:"modified_at"`
|
||||||
Fallbacks SEOFallbacks `json:"fallbacks"`
|
Fallbacks SEOFallbacks `json:"fallbacks"`
|
||||||
JSONLD SEOJSONLD `json:"json_ld"`
|
JSONLD SEOJSONLD `json:"json_ld"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Alternates []Alternate `json:"alternates"`
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
var socialLocalePattern = regexp.MustCompile(`^([A-Za-z]{2,3})_([A-Za-z]{2})$`)
|
var socialLocalePattern = regexp.MustCompile(`^([A-Za-z]{2,3})_([A-Za-z]{2})$`)
|
||||||
|
|
||||||
|
var regionPattern = regexp.MustCompile(`^[A-Za-z]{2}$`)
|
||||||
|
|
||||||
func NormalizeSocialLocale(tag string, social *string) (*string, error) {
|
func NormalizeSocialLocale(tag string, social *string) (*string, error) {
|
||||||
if social == nil {
|
if social == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -25,25 +27,38 @@ func NormalizeSocialLocale(tag string, social *string) (*string, error) {
|
|||||||
return &normalized, nil
|
return &normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SocialLocaleState(row Row) string {
|
func SocialLocaleState(row Row, siteLocale *string) string {
|
||||||
if row.SocialLocale != nil {
|
if row.SocialLocale != nil {
|
||||||
return SocialLocaleSet
|
return SocialLocaleSet
|
||||||
}
|
}
|
||||||
if _, region, found := strings.Cut(row.Tag, "-"); found && region != "" {
|
if EffectiveSocialLocale(row, siteLocale) != nil {
|
||||||
return SocialLocaleDerived
|
return SocialLocaleDerived
|
||||||
}
|
}
|
||||||
return SocialLocaleNotSet
|
return SocialLocaleNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
func EffectiveSocialLocale(row Row) *string {
|
func EffectiveSocialLocale(row Row, siteLocale *string) *string {
|
||||||
switch SocialLocaleState(row) {
|
if row.SocialLocale != nil {
|
||||||
case SocialLocaleSet:
|
|
||||||
return row.SocialLocale
|
return row.SocialLocale
|
||||||
case SocialLocaleDerived:
|
}
|
||||||
|
if _, region, found := strings.Cut(row.Tag, "-"); found && region != "" {
|
||||||
derived := strings.Replace(row.Tag, "-", "_", 1)
|
derived := strings.Replace(row.Tag, "-", "_", 1)
|
||||||
return &derived
|
return &derived
|
||||||
}
|
}
|
||||||
return nil
|
return borrowedSocialLocale(row.Tag, siteLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
func borrowedSocialLocale(tag string, siteLocale *string) *string {
|
||||||
|
if siteLocale == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tagLanguage, _, _ := strings.Cut(tag, "-")
|
||||||
|
parts := strings.Split(*siteLocale, "-")
|
||||||
|
if len(parts) < 2 || !strings.EqualFold(parts[0], tagLanguage) || !regionPattern.MatchString(parts[1]) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
borrowed := strings.ToLower(parts[0]) + "_" + strings.ToUpper(parts[1])
|
||||||
|
return &borrowed
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeEntries(entries []Entry) ([]Entry, error) {
|
func normalizeEntries(entries []Entry) ([]Entry, error) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func Available() []Option {
|
|||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildView(rows []Row, counts map[string]VersionCount) View {
|
func BuildView(rows []Row, counts map[string]VersionCount, siteLocale *string) View {
|
||||||
view := View{Revision: Revision(rows), NeedsSetup: NeedsSetup(rows), Languages: []LanguageView{}, Available: Available()}
|
view := View{Revision: Revision(rows), NeedsSetup: NeedsSetup(rows), Languages: []LanguageView{}, Available: Available()}
|
||||||
if view.NeedsSetup {
|
if view.NeedsSetup {
|
||||||
return view
|
return view
|
||||||
@@ -68,7 +68,7 @@ func BuildView(rows []Row, counts map[string]VersionCount) View {
|
|||||||
VersionCount: counts[row.Tag].Total,
|
VersionCount: counts[row.Tag].Total,
|
||||||
PublishedCount: counts[row.Tag].Published,
|
PublishedCount: counts[row.Tag].Published,
|
||||||
SocialLocale: row.SocialLocale,
|
SocialLocale: row.SocialLocale,
|
||||||
SocialLocaleState: SocialLocaleState(row),
|
SocialLocaleState: SocialLocaleState(row, siteLocale),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package site
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func (s Site) PublicURL(lang string, slug string) *string {
|
||||||
|
if s.PublicURLPattern == nil || lang == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
filled := strings.NewReplacer(LangPlaceholder, lang, SlugPlaceholder, slug).Replace(*s.PublicURLPattern)
|
||||||
|
return &filled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Site) ArticleURL(lang string, slug string) (string, bool) {
|
||||||
|
if localized := s.PublicURL(lang, slug); localized != nil {
|
||||||
|
return *localized, true
|
||||||
|
}
|
||||||
|
return s.LegacyURL(slug), false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Site) LegacyURL(slug string) string {
|
||||||
|
return s.BaseURL + "/" + slug
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ type listArticlesInput struct {
|
|||||||
Sort string `json:"sort,omitempty" jsonschema:"field:direction, e.g. title:asc or updated_at:desc"`
|
Sort string `json:"sort,omitempty" jsonschema:"field:direction, e.g. title:asc or updated_at:desc"`
|
||||||
Site string `json:"site,omitempty" jsonschema:"site key"`
|
Site string `json:"site,omitempty" jsonschema:"site key"`
|
||||||
SEO string `json:"seo,omitempty" jsonschema:"ready, suggestions or hidden"`
|
SEO string `json:"seo,omitempty" jsonschema:"ready, suggestions or hidden"`
|
||||||
|
Lang string `json:"lang,omitempty" jsonschema:"language tag of the versions to list, e.g. en or pt-BR"`
|
||||||
Format string `json:"format,omitempty" jsonschema:"body format: markdown, html or json"`
|
Format string `json:"format,omitempty" jsonschema:"body format: markdown, html or json"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ type getArticleInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createArticleInput struct {
|
type createArticleInput struct {
|
||||||
Title string `json:"title" jsonschema:"3 to 200 characters, cannot be changed later"`
|
Title string `json:"title" jsonschema:"3 to 200 characters"`
|
||||||
Body string `json:"body" jsonschema:"HTML body"`
|
Body string `json:"body" jsonschema:"HTML body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +181,7 @@ func (s *server) listArticles(ctx context.Context, _ *mcp.CallToolRequest, in li
|
|||||||
textQuery(query, "sort", in.Sort)
|
textQuery(query, "sort", in.Sort)
|
||||||
textQuery(query, "site", in.Site)
|
textQuery(query, "site", in.Site)
|
||||||
textQuery(query, "seo", in.SEO)
|
textQuery(query, "seo", in.SEO)
|
||||||
|
textQuery(query, "lang", in.Lang)
|
||||||
textQuery(query, "format", in.Format)
|
textQuery(query, "format", in.Format)
|
||||||
return s.call(ctx, http.MethodGet, "/v1/article/", query, nil)
|
return s.call(ctx, http.MethodGet, "/v1/article/", query, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,22 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
|
|||||||
if filters.Lang != nil {
|
if filters.Lang != nil {
|
||||||
query = query.Where("lang = ?", *filters.Lang)
|
query = query.Where("lang = ?", *filters.Lang)
|
||||||
}
|
}
|
||||||
|
if filters.Langs != nil {
|
||||||
|
query = restrictToLangs(query, *filters.Langs)
|
||||||
|
}
|
||||||
if filters.AuthorID != nil {
|
if filters.AuthorID != nil {
|
||||||
query = query.Where("id IN (SELECT article_id FROM article_authors WHERE author_id = ?)", *filters.AuthorID)
|
query = query.Where("id IN (SELECT article_id FROM article_authors WHERE author_id = ?)", *filters.AuthorID)
|
||||||
}
|
}
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func restrictToLangs(query *gorm.DB, langs []string) *gorm.DB {
|
||||||
|
if len(langs) == 0 {
|
||||||
|
return query.Where("1 = 0")
|
||||||
|
}
|
||||||
|
return query.Where("lang IN ?", langs)
|
||||||
|
}
|
||||||
|
|
||||||
var sortColumns = map[domain.SortField]string{
|
var sortColumns = map[domain.SortField]string{
|
||||||
domain.SortByTitle: "LOWER(title)",
|
domain.SortByTitle: "LOWER(title)",
|
||||||
domain.SortByStatus: "is_online",
|
domain.SortByStatus: "is_online",
|
||||||
@@ -348,6 +358,15 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
|||||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
|
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArticleRepository) GetByLangSlug(siteID uint32, lang string, slug string) (domain.Article, error) {
|
||||||
|
var found entity.Article
|
||||||
|
err := a.db.Model(&entity.Article{}).Where("site_id = ? AND lang = ? AND slug = ?", siteID, lang, slug).First(&found).Error
|
||||||
|
if err != nil {
|
||||||
|
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||||
|
}
|
||||||
|
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(found))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
||||||
created := mappers.ArticleToModel(article.Opened())
|
created := mappers.ArticleToModel(article.Opened())
|
||||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type MediaUsage struct {
|
|||||||
Title string
|
Title string
|
||||||
Published *bool
|
Published *bool
|
||||||
Fields []string
|
Fields []string
|
||||||
|
StoryID *uint32
|
||||||
|
Lang *string
|
||||||
|
|
||||||
ContentTypeKey string
|
ContentTypeKey string
|
||||||
ContentTypeName string
|
ContentTypeName string
|
||||||
@@ -138,7 +140,8 @@ func usageFor(byID map[uint32]*MediaUsage, found article.Article) *MediaUsage {
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
published := found.IsOnline
|
published := found.IsOnline
|
||||||
entry := &MediaUsage{Type: UsageTypeArticle, ID: found.ID, Title: found.Title, Published: &published, Fields: []string{}}
|
storyID, lang := found.StoryID, found.Lang
|
||||||
|
entry := &MediaUsage{Type: UsageTypeArticle, ID: found.ID, Title: found.Title, Published: &published, Fields: []string{}, StoryID: &storyID, Lang: &lang}
|
||||||
byID[found.ID] = entry
|
byID[found.ID] = entry
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
@@ -173,3 +176,23 @@ func (u *GetMediaUsageUseCase) siteUsages(img image.Image) ([]MediaUsage, error)
|
|||||||
func articleMentionsImage(a article.Article, img image.Image) bool {
|
func articleMentionsImage(a article.Article, img image.Image) bool {
|
||||||
return image.ContainsStoredName(a.Body, image.StoredName(img.Path)) || a.UsesImage(img.ID)
|
return image.ContainsStoredName(a.Body, image.StoredName(img.Path)) || a.UsesImage(img.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type articleGroup struct {
|
||||||
|
story uint32
|
||||||
|
article uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupOf(art article.Article) articleGroup {
|
||||||
|
if art.Lang == "" || art.StoryID == 0 {
|
||||||
|
return articleGroup{article: art.ID}
|
||||||
|
}
|
||||||
|
return articleGroup{story: art.StoryID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func distinctArticleCount(articles []article.Article) int {
|
||||||
|
groups := map[articleGroup]bool{}
|
||||||
|
for _, art := range articles {
|
||||||
|
groups[groupOf(art)] = true
|
||||||
|
}
|
||||||
|
return len(groups)
|
||||||
|
}
|
||||||
@@ -19,17 +19,17 @@ func (g *GetSiteLanguagesUseCase) Get(key string) (localization.View, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return localization.View{}, err
|
return localization.View{}, err
|
||||||
}
|
}
|
||||||
return readSiteLanguages(g.localization, found.ID)
|
return readSiteLanguages(g.localization, found)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readSiteLanguages(repository localization.Repository, siteID uint32) (localization.View, error) {
|
func readSiteLanguages(repository localization.Repository, owner site.Site) (localization.View, error) {
|
||||||
rows, err := repository.Rows(siteID)
|
rows, err := repository.Rows(owner.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return localization.View{}, err
|
return localization.View{}, err
|
||||||
}
|
}
|
||||||
counts, err := repository.VersionCounts(siteID)
|
counts, err := repository.VersionCounts(owner.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return localization.View{}, err
|
return localization.View{}, err
|
||||||
}
|
}
|
||||||
return localization.BuildView(rows, counts), nil
|
return localization.BuildView(rows, counts, owner.Locale), nil
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"RenewCMS/internal/domain/image"
|
"RenewCMS/internal/domain/image"
|
||||||
"RenewCMS/internal/domain/site"
|
"RenewCMS/internal/domain/site"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/user"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -160,11 +161,11 @@ func (l *ListMediaUseCase) articleUsageCount(img image.Image) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
distinct := map[uint32]bool{}
|
distinct := map[uint32]article.Article{}
|
||||||
for _, found := range slices.Concat(inBody, referencing) {
|
for _, found := range slices.Concat(inBody, referencing) {
|
||||||
distinct[found.ID] = true
|
distinct[found.ID] = found
|
||||||
}
|
}
|
||||||
return len(distinct), nil
|
return distinctArticleCount(slices.Collect(maps.Values(distinct))), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
|
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
|
||||||
@@ -177,13 +178,13 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return func(img image.Image) int {
|
return func(img image.Image) int {
|
||||||
used := siteUsage[img.ID]
|
mentioning := []article.Article{}
|
||||||
for _, a := range articles {
|
for _, a := range articles {
|
||||||
if articleMentionsImage(a, img) {
|
if articleMentionsImage(a, img) {
|
||||||
used++
|
mentioning = append(mentioning, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return used
|
return siteUsage[img.ID] + distinctArticleCount(mentioning)
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package useCases
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"RenewCMS/internal/domain/article"
|
"RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/domain/language"
|
||||||
|
"RenewCMS/internal/domain/localization"
|
||||||
"RenewCMS/internal/domain/site"
|
"RenewCMS/internal/domain/site"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -9,16 +11,18 @@ import (
|
|||||||
type ListSitemapUseCase struct {
|
type ListSitemapUseCase struct {
|
||||||
articleRepository article.Repository
|
articleRepository article.Repository
|
||||||
sites site.Repository
|
sites site.Repository
|
||||||
|
localization localization.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListSitemapUseCase(articleRepository article.Repository, sites site.Repository) *ListSitemapUseCase {
|
func NewListSitemapUseCase(articleRepository article.Repository, sites site.Repository, localizationRepository localization.Repository) *ListSitemapUseCase {
|
||||||
return &ListSitemapUseCase{articleRepository: articleRepository, sites: sites}
|
return &ListSitemapUseCase{articleRepository: articleRepository, sites: sites, localization: localizationRepository}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SitemapEntry struct {
|
type SitemapEntry struct {
|
||||||
URL string
|
URL string
|
||||||
ModifiedAt time.Time
|
ModifiedAt time.Time
|
||||||
Noindex bool
|
Noindex bool
|
||||||
|
Lang string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SitemapPage struct {
|
type SitemapPage struct {
|
||||||
@@ -31,10 +35,21 @@ func (u *ListSitemapUseCase) List(siteKey string, page int, size int) (SitemapPa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return SitemapPage{}, err
|
return SitemapPage{}, err
|
||||||
}
|
}
|
||||||
|
rows, err := u.localization.Rows(owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
return SitemapPage{}, err
|
||||||
|
}
|
||||||
|
langs := []string{language.Unassigned}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Enabled {
|
||||||
|
langs = append(langs, row.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
online := true
|
online := true
|
||||||
found, err := u.articleRepository.FindPage(article.Filters{
|
found, err := u.articleRepository.FindPage(article.Filters{
|
||||||
IsOnline: &online,
|
IsOnline: &online,
|
||||||
SiteID: &owner.ID,
|
SiteID: &owner.ID,
|
||||||
|
Langs: &langs,
|
||||||
Page: page,
|
Page: page,
|
||||||
Size: size,
|
Size: size,
|
||||||
Sort: article.Sort{Field: article.SortByID, Direction: article.SortAsc},
|
Sort: article.Sort{Field: article.SortByID, Direction: article.SortAsc},
|
||||||
@@ -44,7 +59,12 @@ func (u *ListSitemapUseCase) List(siteKey string, page int, size int) (SitemapPa
|
|||||||
}
|
}
|
||||||
entries := make([]SitemapEntry, 0, len(found.Items))
|
entries := make([]SitemapEntry, 0, len(found.Items))
|
||||||
for _, item := range found.Items {
|
for _, item := range found.Items {
|
||||||
entries = append(entries, SitemapEntry{URL: articleURL(owner, item.Slug), ModifiedAt: item.UpdatedAt, Noindex: item.Noindex})
|
entries = append(entries, SitemapEntry{URL: sitemapURL(owner, item), ModifiedAt: item.UpdatedAt, Noindex: item.Noindex, Lang: item.Lang})
|
||||||
}
|
}
|
||||||
return SitemapPage{Entries: entries, Total: found.Total}, nil
|
return SitemapPage{Entries: entries, Total: found.Total}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sitemapURL(owner site.Site, item article.Article) string {
|
||||||
|
url, _ := owner.ArticleURL(item.Lang, item.Slug)
|
||||||
|
return url
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package useCases
|
|||||||
import (
|
import (
|
||||||
"RenewCMS/internal/domain/article"
|
"RenewCMS/internal/domain/article"
|
||||||
"RenewCMS/internal/domain/image"
|
"RenewCMS/internal/domain/image"
|
||||||
|
"RenewCMS/internal/domain/localization"
|
||||||
"RenewCMS/internal/domain/site"
|
"RenewCMS/internal/domain/site"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,10 +27,53 @@ type ResolveArticleSEOUseCase struct {
|
|||||||
articleRepository article.Repository
|
articleRepository article.Repository
|
||||||
sites site.Repository
|
sites site.Repository
|
||||||
images image.Repository
|
images image.Repository
|
||||||
|
localization localization.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewResolveArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *ResolveArticleSEOUseCase {
|
func NewResolveArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository, localizationRepository localization.Repository) *ResolveArticleSEOUseCase {
|
||||||
return &ResolveArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
|
return &ResolveArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images, localization: localizationRepository}
|
||||||
|
}
|
||||||
|
|
||||||
|
type seoLanguage struct {
|
||||||
|
defaultTag string
|
||||||
|
locale string
|
||||||
|
alternates []article.Alternate
|
||||||
|
localeAlternates []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ResolveArticleSEOUseCase) languageOf(art article.Article, owner site.Site) (seoLanguage, error) {
|
||||||
|
if art.Lang == "" {
|
||||||
|
locale := ""
|
||||||
|
if owner.Locale != nil {
|
||||||
|
locale = *owner.Locale
|
||||||
|
}
|
||||||
|
return seoLanguage{alternates: []article.Alternate{}, localeAlternates: []string{}, locale: locale}, nil
|
||||||
|
}
|
||||||
|
rows, err := u.localization.Rows(art.SiteID)
|
||||||
|
if err != nil {
|
||||||
|
return seoLanguage{}, err
|
||||||
|
}
|
||||||
|
versions, err := storyVersions(u.articleRepository, art)
|
||||||
|
if err != nil {
|
||||||
|
return seoLanguage{}, err
|
||||||
|
}
|
||||||
|
siblings := eligibleSiblings(rows, versions, art)
|
||||||
|
localeAlternates := []string{}
|
||||||
|
for _, sibling := range siblings {
|
||||||
|
if resolved := socialLocaleOf(rows, sibling.Lang, owner.Locale); resolved != "" {
|
||||||
|
localeAlternates = append(localeAlternates, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locale := socialLocaleOf(rows, art.Lang, owner.Locale)
|
||||||
|
if locale == "" {
|
||||||
|
localeAlternates = []string{}
|
||||||
|
}
|
||||||
|
return seoLanguage{
|
||||||
|
defaultTag: confirmedDefault(rows),
|
||||||
|
locale: locale,
|
||||||
|
alternates: alternatesOf(owner, siblings),
|
||||||
|
localeAlternates: localeAlternates,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, caller ContentCaller) (article.ResolvedSEO, error) {
|
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, caller ContentCaller) (article.ResolvedSEO, error) {
|
||||||
@@ -48,7 +92,11 @@ func (u *ResolveArticleSEOUseCase) Resolve(id uint32, caller ContentCaller) (art
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return article.ResolvedSEO{}, err
|
return article.ResolvedSEO{}, err
|
||||||
}
|
}
|
||||||
return buildResolvedSEO(art, owner, picked), nil
|
spoken, err := u.languageOf(art, owner)
|
||||||
|
if err != nil {
|
||||||
|
return article.ResolvedSEO{}, err
|
||||||
|
}
|
||||||
|
return buildResolvedSEO(art, owner, picked, spoken), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ResolveArticleSEOUseCase) resolveImage(art article.Article, owner site.Site) (article.SEOImage, error) {
|
func (u *ResolveArticleSEOUseCase) resolveImage(art article.Article, owner site.Site) (article.SEOImage, error) {
|
||||||
@@ -96,11 +144,11 @@ func resolveCanonical(art article.Article, owner site.Site) (string, string) {
|
|||||||
if art.CanonicalURL != "" {
|
if art.CanonicalURL != "" {
|
||||||
return art.CanonicalURL, article.SEOSourceCanonicalURL
|
return art.CanonicalURL, article.SEOSourceCanonicalURL
|
||||||
}
|
}
|
||||||
return articleURL(owner, art.Slug), article.SEOSourceSite
|
url, patterned := owner.ArticleURL(art.Lang, art.Slug)
|
||||||
}
|
if patterned {
|
||||||
|
return url, article.SEOSourcePattern
|
||||||
func articleURL(owner site.Site, slug string) string {
|
}
|
||||||
return owner.BaseURL + "/" + slug
|
return url, article.SEOSourceSite
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveAuthors(art article.Article) []article.SEOAuthor {
|
func resolveAuthors(art article.Article) []article.SEOAuthor {
|
||||||
@@ -144,10 +192,12 @@ func buildJSONLD(headline string, description string, picked article.SEOImage, a
|
|||||||
return ld
|
return ld
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage) article.ResolvedSEO {
|
func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage, spoken seoLanguage) article.ResolvedSEO {
|
||||||
meta, titleSource := metaTitle(art)
|
meta, titleSource := metaTitle(art)
|
||||||
title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName)
|
title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName)
|
||||||
description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body))
|
siteTextAllowed := art.Lang == "" || art.Lang == spoken.defaultTag
|
||||||
|
description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body), siteTextAllowed)
|
||||||
|
siteTextSkipped := descriptionSource == article.SEOSourceNone && owner.DefaultDescription != ""
|
||||||
canonical, canonicalSource := resolveCanonical(art, owner)
|
canonical, canonicalSource := resolveCanonical(art, owner)
|
||||||
authors := resolveAuthors(art)
|
authors := resolveAuthors(art)
|
||||||
shareTitle := firstNonEmpty(art.SocialTitle, title)
|
shareTitle := firstNonEmpty(art.SocialTitle, title)
|
||||||
@@ -156,10 +206,6 @@ func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOIm
|
|||||||
if picked.URL != "" {
|
if picked.URL != "" {
|
||||||
card = twitterCardLarge
|
card = twitterCardLarge
|
||||||
}
|
}
|
||||||
locale := ""
|
|
||||||
if owner.Locale != nil {
|
|
||||||
locale = *owner.Locale
|
|
||||||
}
|
|
||||||
var published *string
|
var published *string
|
||||||
if art.IsOnline {
|
if art.IsOnline {
|
||||||
formatted := formatSEOTime(art.CreatedAt)
|
formatted := formatSEOTime(art.CreatedAt)
|
||||||
@@ -172,14 +218,16 @@ func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOIm
|
|||||||
Robots: resolveRobots(art, owner),
|
Robots: resolveRobots(art, owner),
|
||||||
OG: article.SEOOpenGraph{
|
OG: article.SEOOpenGraph{
|
||||||
Type: openGraphArticleType, Title: shareTitle, Description: shareDescription, Image: picked.URL, ImageAlt: picked.Alt,
|
Type: openGraphArticleType, Title: shareTitle, Description: shareDescription, Image: picked.URL, ImageAlt: picked.Alt,
|
||||||
URL: canonical, SiteName: owner.SiteName, Locale: locale,
|
URL: canonical, SiteName: owner.SiteName, Locale: spoken.locale, LocaleAlternates: spoken.localeAlternates,
|
||||||
},
|
},
|
||||||
Twitter: article.SEOTwitter{Card: card, Title: shareTitle, Description: shareDescription, Image: picked.URL, ImageAlt: picked.Alt},
|
Twitter: article.SEOTwitter{Card: card, Title: shareTitle, Description: shareDescription, Image: picked.URL, ImageAlt: picked.Alt},
|
||||||
Image: picked,
|
Image: picked,
|
||||||
Authors: authors,
|
Authors: authors,
|
||||||
PublishedAt: published,
|
PublishedAt: published,
|
||||||
ModifiedAt: formatSEOTime(art.UpdatedAt),
|
ModifiedAt: formatSEOTime(art.UpdatedAt),
|
||||||
Fallbacks: article.SEOFallbacks{Title: titleSource, Description: descriptionSource, Image: picked.Source, Canonical: canonicalSource},
|
Fallbacks: article.SEOFallbacks{Title: titleSource, Description: descriptionSource, Image: picked.Source, Canonical: canonicalSource, SiteTextSkipped: siteTextSkipped},
|
||||||
JSONLD: buildJSONLD(meta, description, picked, authors, art, canonical),
|
JSONLD: buildJSONLD(meta, description, picked, authors, art, canonical),
|
||||||
|
Lang: art.Lang,
|
||||||
|
Alternates: spoken.alternates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/domain/language"
|
||||||
|
"RenewCMS/internal/domain/localization"
|
||||||
|
"RenewCMS/internal/domain/site"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResolvePublicArticleUseCase struct {
|
||||||
|
articles article.Repository
|
||||||
|
sites site.Repository
|
||||||
|
localization localization.Repository
|
||||||
|
bodyRenderer article.BodyRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResolvePublicArticleUseCase(articles article.Repository, sites site.Repository, localizationRepository localization.Repository, bodyRenderer article.BodyRenderer) *ResolvePublicArticleUseCase {
|
||||||
|
return &ResolvePublicArticleUseCase{articles: articles, sites: sites, localization: localizationRepository, bodyRenderer: bodyRenderer}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicArticle struct {
|
||||||
|
Article article.Article
|
||||||
|
Viewer ContentCaller
|
||||||
|
Context ArticleContext
|
||||||
|
Alternates []article.Alternate
|
||||||
|
RequestedLang string
|
||||||
|
Fallback bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicStoryQuery struct {
|
||||||
|
SiteKey string
|
||||||
|
StoryID uint32
|
||||||
|
Lang *string
|
||||||
|
FallbackDefault bool
|
||||||
|
Format article.BodyFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicLookupQuery struct {
|
||||||
|
SiteKey string
|
||||||
|
Lang string
|
||||||
|
Slug string
|
||||||
|
Format article.BodyFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ResolvePublicArticleUseCase) ByLangSlug(query PublicLookupQuery) (PublicArticle, error) {
|
||||||
|
tag, err := language.Normalize(query.Lang)
|
||||||
|
if errors.Is(err, language.ErrInvalidLanguage) {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
unsupported := errors.Is(err, language.ErrUnsupportedLanguage)
|
||||||
|
owner, err := u.sites.GetByKey(query.SiteKey)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
if unsupported {
|
||||||
|
return PublicArticle{}, article.ErrArticleNotFound
|
||||||
|
}
|
||||||
|
found, err := u.articles.GetByLangSlug(owner.ID, tag, query.Slug)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
if !found.IsOnline {
|
||||||
|
return PublicArticle{}, article.ErrArticleNotFound
|
||||||
|
}
|
||||||
|
rows, err := u.localization.Rows(owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
versions, err := storyVersions(u.articles, found)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
return u.present(owner, rows, versions, found, query.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ResolvePublicArticleUseCase) ByStory(query PublicStoryQuery) (PublicArticle, error) {
|
||||||
|
requested := ""
|
||||||
|
unsupported := false
|
||||||
|
if query.Lang != nil {
|
||||||
|
tag, err := language.Normalize(*query.Lang)
|
||||||
|
if errors.Is(err, language.ErrInvalidLanguage) {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
requested = tag
|
||||||
|
unsupported = errors.Is(err, language.ErrUnsupportedLanguage)
|
||||||
|
}
|
||||||
|
owner, err := u.sites.GetByKey(query.SiteKey)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
rows, err := u.localization.Rows(owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
if unsupported {
|
||||||
|
return PublicArticle{}, article.ErrLanguageNotAvailable
|
||||||
|
}
|
||||||
|
defaultTag := confirmedDefault(rows)
|
||||||
|
if query.Lang == nil {
|
||||||
|
requested = defaultTag
|
||||||
|
}
|
||||||
|
versions, err := u.articles.ListByStory(query.StoryID)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
chosen, fallback, ok := chooseStoryVersion(owner, versions, requested, defaultTag, query.FallbackDefault)
|
||||||
|
if !ok {
|
||||||
|
return PublicArticle{}, article.ErrLanguageNotAvailable
|
||||||
|
}
|
||||||
|
presented, err := u.present(owner, rows, versions, chosen, query.Format)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
presented.RequestedLang = requested
|
||||||
|
presented.Fallback = fallback
|
||||||
|
return presented, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseStoryVersion(owner site.Site, versions []article.Article, requested string, defaultTag string, fallbackDefault bool) (article.Article, bool, bool) {
|
||||||
|
if requested == "" {
|
||||||
|
return article.Article{}, false, false
|
||||||
|
}
|
||||||
|
if found, ok := publishedVersion(owner, versions, requested); ok {
|
||||||
|
return found, false, true
|
||||||
|
}
|
||||||
|
if fallbackDefault && defaultTag != "" && defaultTag != requested {
|
||||||
|
if found, ok := publishedVersion(owner, versions, defaultTag); ok {
|
||||||
|
return found, true, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return article.Article{}, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishedVersion(owner site.Site, versions []article.Article, tag string) (article.Article, bool) {
|
||||||
|
for _, version := range versions {
|
||||||
|
if version.SiteID == owner.ID && version.Lang == tag && version.IsOnline {
|
||||||
|
return version, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return article.Article{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ResolvePublicArticleUseCase) present(owner site.Site, rows []localization.Row, versions []article.Article, found article.Article, format article.BodyFormat) (PublicArticle, error) {
|
||||||
|
rendered, err := renderArticle(u.bodyRenderer, found, format)
|
||||||
|
if err != nil {
|
||||||
|
return PublicArticle{}, err
|
||||||
|
}
|
||||||
|
viewer := ContentCaller{}
|
||||||
|
described := ArticleContext{Capabilities: viewer.capabilitiesFor(found), Translations: []article.Translation{}}
|
||||||
|
return PublicArticle{Article: rendered, Viewer: viewer, Context: described, Alternates: alternatesOf(owner, eligibleSiblings(rows, versions, found))}, nil
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateS
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
view, err = readSiteLanguages(tx.Localization, found.ID)
|
view, err = readSiteLanguages(tx.Localization, found)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if errors.Is(err, transaction.ErrLockContention) {
|
if errors.Is(err, transaction.ErrLockContention) {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/domain/localization"
|
||||||
|
"RenewCMS/internal/domain/site"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func confirmedDefault(rows []localization.Row) string {
|
||||||
|
if localization.NeedsSetup(rows) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return localization.DefaultTag(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func eligibleSiblings(rows []localization.Row, versions []article.Article, served article.Article) []article.Article {
|
||||||
|
defaultTag := confirmedDefault(rows)
|
||||||
|
siblings := []article.Article{}
|
||||||
|
for _, version := range versions {
|
||||||
|
if version.ID == served.ID || version.SiteID != served.SiteID || !version.IsOnline || version.Lang == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if localization.EnabledTag(rows, version.Lang) {
|
||||||
|
siblings = append(siblings, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(siblings, func(left article.Article, right article.Article) int {
|
||||||
|
return localization.CompareTags(defaultTag, left.Lang, right.Lang)
|
||||||
|
})
|
||||||
|
return siblings
|
||||||
|
}
|
||||||
|
|
||||||
|
func storyVersions(repository article.Repository, art article.Article) ([]article.Article, error) {
|
||||||
|
if art.StoryID == 0 {
|
||||||
|
return []article.Article{}, nil
|
||||||
|
}
|
||||||
|
return repository.ListByStory(art.StoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func alternatesOf(owner site.Site, siblings []article.Article) []article.Alternate {
|
||||||
|
alternates := make([]article.Alternate, 0, len(siblings))
|
||||||
|
for _, sibling := range siblings {
|
||||||
|
alternates = append(alternates, article.Alternate{ID: sibling.ID, Lang: sibling.Lang, StoryID: sibling.StoryID, Slug: sibling.Slug, URL: owner.PublicURL(sibling.Lang, sibling.Slug)})
|
||||||
|
}
|
||||||
|
return alternates
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowOf(rows []localization.Row, tag string) localization.Row {
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Tag == tag {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localization.Row{Tag: tag}
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialLocaleOf(rows []localization.Row, tag string, siteLocale *string) string {
|
||||||
|
if resolved := localization.EffectiveSocialLocale(rowOf(rows, tag), siteLocale); resolved != nil {
|
||||||
|
return *resolved
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ func metaTitle(art article.Article) (string, string) {
|
|||||||
return art.Title, article.SEOSourceTitle
|
return art.Title, article.SEOSourceTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts) (string, string) {
|
const withSiteText = true
|
||||||
|
|
||||||
|
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) (string, string) {
|
||||||
if art.SEODescription != "" {
|
if art.SEODescription != "" {
|
||||||
return art.SEODescription, article.SEOSourceSEODescription
|
return art.SEODescription, article.SEOSourceSEODescription
|
||||||
}
|
}
|
||||||
@@ -25,7 +27,7 @@ func resolveDescription(art article.Article, owner site.Site, facts bodyFacts) (
|
|||||||
if sentence := firstSentence(facts.spacedText); sentence != "" {
|
if sentence := firstSentence(facts.spacedText); sentence != "" {
|
||||||
return sentence, article.SEOSourceBody
|
return sentence, article.SEOSourceBody
|
||||||
}
|
}
|
||||||
if owner.DefaultDescription != "" {
|
if siteTextAllowed && owner.DefaultDescription != "" {
|
||||||
return owner.DefaultDescription, article.SEOSourceSite
|
return owner.DefaultDescription, article.SEOSourceSite
|
||||||
}
|
}
|
||||||
return "", article.SEOSourceNone
|
return "", article.SEOSourceNone
|
||||||
@@ -45,7 +47,7 @@ func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []art
|
|||||||
if strings.TrimSpace(title) == "" {
|
if strings.TrimSpace(title) == "" {
|
||||||
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
|
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
|
||||||
}
|
}
|
||||||
description, _ := resolveDescription(art, owner, facts)
|
description, _ := resolveDescription(art, owner, facts, withSiteText)
|
||||||
descriptionRunes := utf8.RuneCountInString(description)
|
descriptionRunes := utf8.RuneCountInString(description)
|
||||||
if descriptionRunes < article.SEODescriptionMinRunes {
|
if descriptionRunes < article.SEODescriptionMinRunes {
|
||||||
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))
|
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
|
|||||||
if filters.Lang != nil && a.Lang != *filters.Lang {
|
if filters.Lang != nil && a.Lang != *filters.Lang {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if filters.Langs != nil && !slices.Contains(*filters.Langs, a.Lang) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if filters.AuthorID != nil && !slices.Contains(m.credits[a.ID], *filters.AuthorID) {
|
if filters.AuthorID != nil && !slices.Contains(m.credits[a.ID], *filters.AuthorID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -460,6 +463,18 @@ func (m *mockArticleRepo) storyHasLanguage(storyID uint32, lang string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockArticleRepo) GetByLangSlug(siteID uint32, lang string, slug string) (article.Article, error) {
|
||||||
|
if err := m.fail.trigger("GetByLangSlug"); err != nil {
|
||||||
|
return article.Article{}, err
|
||||||
|
}
|
||||||
|
for _, id := range m.sortedIds() {
|
||||||
|
if stored := m.articles[id]; stored.SiteID == siteID && stored.Lang == lang && stored.Slug == slug {
|
||||||
|
return m.withAuthors(stored), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return article.Article{}, article.ErrArticleNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockArticleRepo) ListByStory(storyID uint32) ([]article.Article, error) {
|
func (m *mockArticleRepo) ListByStory(storyID uint32) ([]article.Article, error) {
|
||||||
if err := m.fail.trigger("ListByStory"); err != nil {
|
if err := m.fail.trigger("ListByStory"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -283,6 +283,9 @@ func (m *mockAuthorRepo) AvatarUsage() (map[uint32]int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockAuthorRepo) FindByAvatar(imageID uint32) ([]author.Author, error) {
|
func (m *mockAuthorRepo) FindByAvatar(imageID uint32) ([]author.Author, error) {
|
||||||
|
if err := m.fail.trigger("FindByAvatar"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
found := []author.Author{}
|
found := []author.Author{}
|
||||||
for _, id := range m.sortedIDsAscending() {
|
for _, id := range m.sortedIDsAscending() {
|
||||||
if a := m.authors[id]; a.AvatarID != nil && *a.AvatarID == imageID {
|
if a := m.authors[id]; a.AvatarID != nil && *a.AvatarID == imageID {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/localization"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalizationCompareTags(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
name string
|
||||||
|
left string
|
||||||
|
right string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"same tag", "fr", "fr", 0},
|
||||||
|
{"same default tag", "en", "en", 0},
|
||||||
|
{"default first", "en", "de", -1},
|
||||||
|
{"default last argument", "de", "en", 1},
|
||||||
|
{"alphabetical before", "de", "fr", -1},
|
||||||
|
{"alphabetical after", "fr", "de", 1},
|
||||||
|
}
|
||||||
|
for _, example := range examples {
|
||||||
|
t.Run(example.name, func(t *testing.T) {
|
||||||
|
if got := localization.CompareTags("en", example.left, example.right); got != example.want {
|
||||||
|
t.Errorf("CompareTags(%q, %q) = %d, want %d", example.left, example.right, got, example.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizationBuildViewOrdersDefaultThenEnabledThenTag(t *testing.T) {
|
||||||
|
rows := []localization.Row{
|
||||||
|
{Tag: "pt", Enabled: false, Confirmed: true},
|
||||||
|
{Tag: "fr", Enabled: true, Confirmed: true},
|
||||||
|
{Tag: "es", Enabled: false, Confirmed: true},
|
||||||
|
{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
|
||||||
|
{Tag: "de", Enabled: true, Confirmed: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
view := localization.BuildView(rows, map[string]localization.VersionCount{}, nil)
|
||||||
|
|
||||||
|
order := []string{}
|
||||||
|
for _, language := range view.Languages {
|
||||||
|
order = append(order, language.Lang)
|
||||||
|
}
|
||||||
|
if want := []string{"en", "de", "fr", "es", "pt"}; !reflect.DeepEqual(order, want) {
|
||||||
|
t.Errorf("order = %v, want %v", order, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
"RenewCMS/internal/domain/localization"
|
"RenewCMS/internal/domain/localization"
|
||||||
"RenewCMS/internal/domain/site"
|
"RenewCMS/internal/domain/site"
|
||||||
"RenewCMS/internal/infrastructure/persistence/database"
|
"RenewCMS/internal/infrastructure/persistence/database"
|
||||||
@@ -229,6 +230,47 @@ func TestLocalizationPersistence(t *testing.T) {
|
|||||||
t.Errorf("retry = %+v, %v", again, err)
|
t.Errorf("retry = %+v, %v", again, err)
|
||||||
}
|
}
|
||||||
}},
|
}},
|
||||||
|
{"Exact lookup and story resolution read the same rows", func(t *testing.T, db *gorm.DB) {
|
||||||
|
created := seedLocalizedSite(t, db, "lookups", nil, nil)
|
||||||
|
repository := persistence.NewArticleRepository(db)
|
||||||
|
story := fixtureStory()
|
||||||
|
published := entity.Article{Title: "Published", SiteID: created.ID, Lang: "fr", StoryID: story, Slug: "publie", IsOnline: true}
|
||||||
|
offline := entity.Article{Title: "Offline", SiteID: created.ID, Lang: "en", StoryID: story, Slug: "offline"}
|
||||||
|
unassigned := entity.Article{Title: "Unassigned", SiteID: created.ID, StoryID: fixtureStory(), Slug: "legacy", IsOnline: true}
|
||||||
|
removed := entity.Article{Title: "Removed", SiteID: created.ID, Lang: "de", StoryID: story, Slug: "weg", IsOnline: true}
|
||||||
|
for _, stored := range []*entity.Article{&published, &offline, &unassigned, &removed} {
|
||||||
|
if err := db.Create(stored).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Delete(&removed).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := repository.GetByLangSlug(created.ID, "fr", "publie")
|
||||||
|
missingLanguage, missingErr := repository.GetByLangSlug(created.ID, "en", "publie")
|
||||||
|
_, removedErr := repository.GetByLangSlug(created.ID, "de", "weg")
|
||||||
|
_, unassignedErr := repository.GetByLangSlug(created.ID, "en", "legacy")
|
||||||
|
versions, listErr := repository.ListByStory(story)
|
||||||
|
|
||||||
|
if err != nil || found.ID != published.ID || !found.IsOnline {
|
||||||
|
t.Errorf("exact lookup = %+v, %v", found, err)
|
||||||
|
}
|
||||||
|
if !errors.Is(missingErr, domainArticle.ErrArticleNotFound) || missingLanguage.ID != 0 || !errors.Is(removedErr, domainArticle.ErrArticleNotFound) || !errors.Is(unassignedErr, domainArticle.ErrArticleNotFound) {
|
||||||
|
t.Errorf("misses = %v %v %v", missingErr, removedErr, unassignedErr)
|
||||||
|
}
|
||||||
|
if listErr != nil || len(versions) != 2 {
|
||||||
|
t.Errorf("story versions = %d, %v", len(versions), listErr)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"idx_articles_site_slug", "idx_articles_story_lang"} {
|
||||||
|
if !db.Migrator().HasIndex(&entity.Article{}, name) {
|
||||||
|
t.Errorf("index %s is missing", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||||
|
t.Errorf("a second migration failed: %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
for _, backend := range backends {
|
for _, backend := range backends {
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -0,0 +1,832 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api"
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/domain/localization"
|
||||||
|
"RenewCMS/internal/domain/site"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
locJournalID = 11
|
||||||
|
locAtelierID = 12
|
||||||
|
locNotesID = 13
|
||||||
|
locPattern = "https://journal.example.com/{lang}/{slug}"
|
||||||
|
|
||||||
|
locEnSlug = "how-we-price-a-bathroom-renovation"
|
||||||
|
locFrSlug = "comment-nous-chiffrons-une-renovation"
|
||||||
|
locDeSlug = "so-kalkulieren-wir-eine-badsanierung"
|
||||||
|
locFrBase = "/v1/public/site/journal/lang/fr/article/" + locFrSlug
|
||||||
|
)
|
||||||
|
|
||||||
|
type locPublicEnv struct {
|
||||||
|
*seoEnv
|
||||||
|
store *fakeLocalizationStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLocPublicEnv(t *testing.T) *locPublicEnv {
|
||||||
|
t.Helper()
|
||||||
|
e := newSeoEnv(t)
|
||||||
|
for id := range e.sites.sites {
|
||||||
|
delete(e.sites.sites, id)
|
||||||
|
}
|
||||||
|
e.articles.articles = map[uint32]article.Article{}
|
||||||
|
store := newFakeLocalizationStore(e.articles)
|
||||||
|
renderer := &fakeBodyRenderer{}
|
||||||
|
c := e.container
|
||||||
|
c.GetArticleUseCase = useCases.NewGetArticleUseCase(e.articles, renderer)
|
||||||
|
c.DescribeArticleUseCase = useCases.NewDescribeArticleUseCase(e.articles, store)
|
||||||
|
c.ResolvePublicArticleUseCase = useCases.NewResolvePublicArticleUseCase(e.articles, e.sites, store, renderer)
|
||||||
|
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(e.articles, e.sites, e.images, store)
|
||||||
|
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(e.articles, e.sites, store)
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
|
||||||
|
e.router = router
|
||||||
|
pattern := locPattern
|
||||||
|
locale := "en-GB"
|
||||||
|
journal := e.sites.seed(locJournalID, "Journal", "journal", true)
|
||||||
|
journal.BaseURL = "https://www.journal.example"
|
||||||
|
journal.Locale = &locale
|
||||||
|
journal.SiteName = "Journal"
|
||||||
|
journal.TitleTemplate = "%title% | %site%"
|
||||||
|
journal.DefaultDescription = "Renovation notes."
|
||||||
|
journal.PublicURLPattern = &pattern
|
||||||
|
e.sites.sites[locJournalID] = journal
|
||||||
|
e.sites.seed(locAtelierID, "Atelier", "atelier", false)
|
||||||
|
e.sites.seed(locNotesID, "Notes", "notes", false)
|
||||||
|
frSocial := "fr_FR"
|
||||||
|
store.rows[locJournalID] = []localization.Row{
|
||||||
|
{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
|
||||||
|
{Tag: "fr", Enabled: true, Confirmed: true, SocialLocale: &frSocial},
|
||||||
|
{Tag: "de", Enabled: true, Confirmed: true},
|
||||||
|
{Tag: "ar", Enabled: true, Confirmed: true},
|
||||||
|
{Tag: "es", Confirmed: true},
|
||||||
|
{Tag: "pt-BR", Enabled: true, Confirmed: true},
|
||||||
|
{Tag: "fr-CA", Enabled: true, Confirmed: true},
|
||||||
|
}
|
||||||
|
store.rows[locAtelierID] = []localization.Row{{Tag: "fr", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "en", Enabled: true, Confirmed: true}}
|
||||||
|
e.version(10, "en", locEnSlug, "How we price a bathroom renovation", 5, true)
|
||||||
|
e.version(12, "fr", locFrSlug, "Comment nous chiffrons une rénovation", 5, true)
|
||||||
|
e.version(11, "de", locDeSlug, "So kalkulieren wir eine Badsanierung", 5, true)
|
||||||
|
e.version(13, "ar", "takalif", "Takalif", 5, false)
|
||||||
|
e.version(14, "es", "cuanto-cuesta", "Cuánto cuesta", 5, true)
|
||||||
|
e.version(20, "en", "spring", "Spring", 6, true)
|
||||||
|
e.version(21, "en", "winter", "Winter", 7, false)
|
||||||
|
e.version(30, "", "legacy-note", "Legacy note", 30, true)
|
||||||
|
e.version(40, "pt-BR", "reforma", "Reforma", 8, true)
|
||||||
|
e.version(41, "fr-CA", "renovation", "Rénovation", 9, true)
|
||||||
|
e.stored(30)
|
||||||
|
return &locPublicEnv{seoEnv: e, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *seoEnv) version(id uint32, lang string, slug string, title string, story uint32, online bool) {
|
||||||
|
owner := uint32(locJournalID)
|
||||||
|
e.articles.articles[id] = article.Article{
|
||||||
|
ID: id, Title: title, Body: "<p>" + title + ".</p>", IsOnline: online, SiteID: owner, Lang: lang, StoryID: story, Slug: slug,
|
||||||
|
SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutModified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *locPublicEnv) change(id uint32, mutate func(*article.Article)) {
|
||||||
|
found := e.articles.articles[id]
|
||||||
|
mutate(&found)
|
||||||
|
e.articles.articles[id] = found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *locPublicEnv) changeSite(id uint32, mutate func(*site.Site)) {
|
||||||
|
found := e.sites.sites[id]
|
||||||
|
mutate(&found)
|
||||||
|
e.sites.sites[id] = found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *locPublicEnv) get(t *testing.T, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *locPublicEnv) json(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
rec := e.get(t, path, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d %q", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func alternateIDs(object map[string]any) []float64 {
|
||||||
|
ids := []float64{}
|
||||||
|
for _, entry := range object["alternates"].([]any) {
|
||||||
|
ids = append(ids, entry.(map[string]any)["id"].(float64))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizationPublicExactLookup(t *testing.T) {
|
||||||
|
t.Run("A published version is returned with its alternates", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
body := e.json(t, locFrBase)
|
||||||
|
|
||||||
|
direct := e.json(t, "/v1/article/12")
|
||||||
|
alternates := body["alternates"]
|
||||||
|
delete(body, "alternates")
|
||||||
|
if !reflect.DeepEqual(body, direct) || body["id"] != float64(12) || body["lang"] != "fr" || body["story_id"] != float64(5) || body["is_online"] != true {
|
||||||
|
t.Errorf("body differs from GET /v1/article/12: %v", body)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(alternates, []any{
|
||||||
|
map[string]any{"id": float64(10), "lang": "en", "story_id": float64(5), "slug": locEnSlug, "url": "https://journal.example.com/en/" + locEnSlug},
|
||||||
|
map[string]any{"id": float64(11), "lang": "de", "story_id": float64(5), "slug": locDeSlug, "url": "https://journal.example.com/de/" + locDeSlug},
|
||||||
|
}) {
|
||||||
|
t.Errorf("alternates = %v", alternates)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Alternates leave out the served version, drafts and disabled languages", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
rec := e.get(t, "/v1/public/site/journal/lang/en/article/"+locEnSlug, nil)
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||||
|
if ids := alternateIDs(body); !reflect.DeepEqual(ids, []float64{11, 12}) {
|
||||||
|
t.Errorf("alternates = %v", ids)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "takalif") || strings.Contains(rec.Body.String(), "cuanto-cuesta") {
|
||||||
|
t.Errorf("a hidden sibling is named: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A disabled language stays readable by exact lookup", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
body := e.json(t, "/v1/public/site/journal/lang/es/article/cuanto-cuesta")
|
||||||
|
|
||||||
|
if body["lang"] != "es" || !reflect.DeepEqual(alternateIDs(body), []float64{10, 11, 12}) {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A story with one version has an empty alternates array", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
rec := e.get(t, "/v1/public/site/journal/lang/en/article/spring", nil)
|
||||||
|
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||||
|
if rec.Code != 200 || string(body["alternates"]) != "[]" {
|
||||||
|
t.Errorf("%d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The lookup matches site, language and slug only", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"journal/lang/de/article/" + locFrSlug, 404, "article_not_found"},
|
||||||
|
{"journal/lang/fr/article/" + locEnSlug, 404, "article_not_found"},
|
||||||
|
{"journal/lang/ar/article/takalif", 404, "article_not_found"},
|
||||||
|
{"journal/lang/en/article/winter", 404, "article_not_found"},
|
||||||
|
{"journal/lang/en/article/nope", 404, "article_not_found"},
|
||||||
|
{"journal/lang/FR/article/" + locFrSlug, 200, ""},
|
||||||
|
{"journal/lang/pt-br/article/spring", 404, "article_not_found"},
|
||||||
|
{"journal/lang/xx/article/spring", 404, "article_not_found"},
|
||||||
|
{"journal/lang/e/article/spring", 400, "invalid_language"},
|
||||||
|
{"journal/lang/pt_BR/article/spring", 400, "invalid_language"},
|
||||||
|
{"nowhere/lang/fr/article/" + locFrSlug, 404, "site_not_found"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.path, func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
rec := e.get(t, "/v1/public/site/"+c.path, nil)
|
||||||
|
|
||||||
|
if rec.Code != c.status || (c.body != "" && strings.TrimSpace(rec.Body.String()) != c.body) {
|
||||||
|
t.Errorf("%d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The same slug in another site or language is a different page", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.articles.articles[50] = article.Article{ID: 50, Title: "Atelier", IsOnline: true, SiteID: locAtelierID, Lang: "fr", StoryID: 50, Slug: locFrSlug, SEODismissed: []string{}}
|
||||||
|
|
||||||
|
atelier := e.json(t, "/v1/public/site/atelier/lang/fr/article/"+locFrSlug)
|
||||||
|
journal := e.json(t, locFrBase)
|
||||||
|
|
||||||
|
if atelier["site_id"] != float64(locAtelierID) || journal["id"] != float64(12) {
|
||||||
|
t.Errorf("atelier %v journal %v", atelier["site_id"], journal["id"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An unassigned article has no language route", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
rec := e.get(t, "/v1/public/site/journal/lang/en/article/legacy-note", nil)
|
||||||
|
|
||||||
|
expectStatusAndText(t, rec, 404, "article_not_found")
|
||||||
|
if direct := e.get(t, "/v1/article/30", nil); direct.Code != 200 {
|
||||||
|
t.Errorf("direct read = %d", direct.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A draft never answers, even to its owner", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
rec := e.get(t, "/v1/public/site/journal/lang/en/article/winter", e.cookie(t, apiKeyOwnerId, 0))
|
||||||
|
|
||||||
|
expectStatusAndText(t, rec, 404, "article_not_found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Unpublishing removes the version at once", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(12, func(a *article.Article) { a.IsOnline = false })
|
||||||
|
|
||||||
|
rec := e.get(t, locFrBase, nil)
|
||||||
|
other := e.json(t, "/v1/public/site/journal/lang/en/article/"+locEnSlug)
|
||||||
|
|
||||||
|
expectStatusAndText(t, rec, 404, "article_not_found")
|
||||||
|
if !reflect.DeepEqual(alternateIDs(other), []float64{11}) {
|
||||||
|
t.Errorf("alternates = %v", alternateIDs(other))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A changed slug breaks the old address and creates no redirect", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(12, func(a *article.Article) { a.Slug = "nouveau-titre" })
|
||||||
|
|
||||||
|
old := e.get(t, locFrBase, nil)
|
||||||
|
renewed := e.get(t, "/v1/public/site/journal/lang/fr/article/nouveau-titre", nil)
|
||||||
|
|
||||||
|
expectStatusAndText(t, old, 404, "article_not_found")
|
||||||
|
if renewed.Code != 200 {
|
||||||
|
t.Errorf("new address = %d", renewed.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The body format parameter behaves as on GET /v1/article/{id}", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(20, func(a *article.Article) { a.Body = "[]" })
|
||||||
|
|
||||||
|
body := e.json(t, "/v1/public/site/journal/lang/en/article/spring?format=json")
|
||||||
|
bad := e.get(t, "/v1/public/site/journal/lang/en/article/spring?format=pdf", nil)
|
||||||
|
|
||||||
|
expectStatusAndText(t, bad, 400, apperr.ArticleBodyFormatErrorMessage)
|
||||||
|
if body["body_format"] != "json" {
|
||||||
|
t.Errorf("body_format = %v", body["body_format"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A repository failure answers 500", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.articles.fail.failNext("GetByLangSlug")
|
||||||
|
|
||||||
|
rec := e.get(t, "/v1/public/site/journal/lang/en/article/spring", nil)
|
||||||
|
|
||||||
|
expectStatusAndText(t, rec, 500, "Internal server error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizationPublicStoryResolution(t *testing.T) {
|
||||||
|
story := "/v1/public/site/journal/story/5"
|
||||||
|
|
||||||
|
t.Run("The requested language is published", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
body := e.json(t, story+"?lang=fr")
|
||||||
|
|
||||||
|
if body["id"] != float64(12) || body["lang"] != "fr" || body["requested_lang"] != "fr" || body["fallback"] != false || !reflect.DeepEqual(alternateIDs(body), []float64{10, 11}) {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The requested language is missing and no fallback is asked", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
delete(e.articles.articles, 12)
|
||||||
|
|
||||||
|
expectStatusAndText(t, e.get(t, story+"?lang=fr", nil), 404, "language_not_available")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The requested language is missing and the default is the fallback", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
delete(e.articles.articles, 12)
|
||||||
|
|
||||||
|
body := e.json(t, story+"?lang=fr&fallback=default")
|
||||||
|
|
||||||
|
if body["id"] != float64(10) || body["lang"] != "en" || body["requested_lang"] != "fr" || body["fallback"] != true {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The requested language is unpublished, not only missing", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(12, func(a *article.Article) { a.IsOnline = false })
|
||||||
|
|
||||||
|
body := e.json(t, story+"?lang=fr&fallback=default")
|
||||||
|
|
||||||
|
if body["id"] != float64(10) || body["fallback"] != true {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Neither the requested language nor the default is published", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(12, func(a *article.Article) { a.IsOnline = false })
|
||||||
|
e.change(10, func(a *article.Article) { a.IsOnline = false })
|
||||||
|
|
||||||
|
rec := e.get(t, story+"?lang=fr&fallback=default", nil)
|
||||||
|
|
||||||
|
expectStatusAndText(t, rec, 404, "language_not_available")
|
||||||
|
if strings.Contains(rec.Body.String(), "so-kalkulieren") {
|
||||||
|
t.Errorf("a third language was returned")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The default language itself is requested and unpublished", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(10, func(a *article.Article) { a.IsOnline = false })
|
||||||
|
|
||||||
|
expectStatusAndText(t, e.get(t, story+"?lang=en&fallback=default", nil), 404, "language_not_available")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("No language means the site default language", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
body := e.json(t, story)
|
||||||
|
|
||||||
|
if body["id"] != float64(10) || body["lang"] != "en" || body["requested_lang"] != "en" || body["fallback"] != false {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Changing the default changes only requests that omit the language", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
rows := e.store.rows[locJournalID]
|
||||||
|
for index := range rows {
|
||||||
|
rows[index].IsDefault = rows[index].Tag == "fr"
|
||||||
|
}
|
||||||
|
|
||||||
|
omitted := e.json(t, story)
|
||||||
|
|
||||||
|
if omitted["id"] != float64(12) || omitted["requested_lang"] != "fr" {
|
||||||
|
t.Errorf("omitted = %v", omitted)
|
||||||
|
}
|
||||||
|
if e.json(t, story+"?lang=en")["id"] != float64(10) || e.json(t, "/v1/public/site/journal/lang/en/article/"+locEnSlug)["id"] != float64(10) || e.json(t, "/v1/article/10")["id"] != float64(10) {
|
||||||
|
t.Errorf("explicit requests changed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A story of a site with no confirmed default cannot be resolved without a language", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.articles.articles[60] = article.Article{ID: 60, Title: "Note", IsOnline: true, SiteID: locNotesID, Lang: "en", StoryID: 60, Slug: "note", SEODismissed: []string{}}
|
||||||
|
e.store.rows[locNotesID] = []localization.Row{{Tag: "en", IsDefault: true, Enabled: true}}
|
||||||
|
|
||||||
|
expectStatusAndText(t, e.get(t, "/v1/public/site/notes/story/60", nil), 404, "language_not_available")
|
||||||
|
if rec := e.get(t, "/v1/public/site/notes/story/60?lang=en", nil); rec.Code != 200 {
|
||||||
|
t.Errorf("explicit lang = %d", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A disabled language is served when asked explicitly", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
if body := e.json(t, story+"?lang=es"); body["lang"] != "es" {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Refused and unknown requests", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"journal/story/6?lang=fr", 404, "language_not_available"},
|
||||||
|
{"journal/story/7?lang=en", 404, "language_not_available"},
|
||||||
|
{"journal/story/999?lang=en", 404, "language_not_available"},
|
||||||
|
{"journal/story/5?lang=fr&fallback=latest", 400, apperr.BodyErrorMessage},
|
||||||
|
{"journal/story/5?lang=fr&fallback=", 400, apperr.BodyErrorMessage},
|
||||||
|
{"journal/story/5?lang=english", 400, "invalid_language"},
|
||||||
|
{"journal/story/abc?lang=en", 400, apperr.IdUint32ErrorMessage},
|
||||||
|
{"nowhere/story/5?lang=en", 404, "site_not_found"},
|
||||||
|
{"atelier/story/5?lang=en", 404, "language_not_available"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.path, func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
expectStatusAndText(t, e.get(t, "/v1/public/site/"+c.path, nil), c.status, c.body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The response carries no more than a direct read would", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
body := e.json(t, story+"?lang=fr")
|
||||||
|
|
||||||
|
exact := e.json(t, locFrBase)
|
||||||
|
delete(body, "requested_lang")
|
||||||
|
delete(body, "fallback")
|
||||||
|
if sortedKeys(body) != sortedKeys(exact) {
|
||||||
|
t.Errorf("keys %s vs %s", sortedKeys(body), sortedKeys(exact))
|
||||||
|
}
|
||||||
|
if _, leaked := body["created_by"]; leaked {
|
||||||
|
t.Errorf("created_by is present")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizationSEOOutput(t *testing.T) {
|
||||||
|
t.Run("The shape grows by additive keys", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
body := e.json(t, "/v1/article/12/seo")
|
||||||
|
|
||||||
|
if body["lang"] != "fr" || !reflect.DeepEqual(alternateIDs(body), []float64{10, 11}) {
|
||||||
|
t.Errorf("lang/alternates = %v %v", body["lang"], body["alternates"])
|
||||||
|
}
|
||||||
|
if _, ok := nested(body, "og")["locale_alternates"]; !ok {
|
||||||
|
t.Errorf("og.locale_alternates missing")
|
||||||
|
}
|
||||||
|
if _, ok := nested(body, "fallbacks")["site_text_skipped"]; !ok {
|
||||||
|
t.Errorf("fallbacks.site_text_skipped missing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An unassigned article keeps the legacy values", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
body := e.json(t, "/v1/article/30/seo")
|
||||||
|
|
||||||
|
og := nested(body, "og")
|
||||||
|
if body["lang"] != "" || len(body["alternates"].([]any)) != 0 || len(og["locale_alternates"].([]any)) != 0 || body["canonical_url"] != "https://www.journal.example/legacy-note" || og["locale"] != "en-GB" {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The canonical is the localized page unless overridden", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
pattern *string
|
||||||
|
id uint32
|
||||||
|
override string
|
||||||
|
want string
|
||||||
|
source string
|
||||||
|
}{
|
||||||
|
{ptrText(locPattern), 12, "", "https://journal.example.com/fr/" + locFrSlug, "pattern"},
|
||||||
|
{ptrText("https://journal.example.com/{lang}/articles/{slug}"), 11, "", "https://journal.example.com/de/articles/" + locDeSlug, "pattern"},
|
||||||
|
{ptrText(locPattern), 12, "https://other.example/a", "https://other.example/a", "canonical_url"},
|
||||||
|
{nil, 12, "", "https://www.journal.example/" + locFrSlug, "site"},
|
||||||
|
{ptrText("https://journal.example.com/{slug}"), 12, "", "https://journal.example.com/" + locFrSlug, "pattern"},
|
||||||
|
{ptrText(locPattern), 40, "", "https://journal.example.com/pt-BR/reforma", "pattern"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.want, func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = c.pattern })
|
||||||
|
e.change(c.id, func(a *article.Article) { a.CanonicalURL = c.override })
|
||||||
|
|
||||||
|
body := e.json(t, "/v1/article/"+uintText(c.id)+"/seo")
|
||||||
|
|
||||||
|
if body["canonical_url"] != c.want || nested(body, "og")["url"] != c.want || nested(body, "fallbacks")["canonical"] != c.source || nested(nested(body, "json_ld"), "mainEntityOfPage")["@id"] != c.want {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Saving the pattern now changes canonical output", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = nil })
|
||||||
|
e.change(11, func(a *article.Article) { a.CanonicalURL = "https://other.example/de" })
|
||||||
|
before := e.json(t, "/v1/article/12/seo")["canonical_url"]
|
||||||
|
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = ptrText(locPattern) })
|
||||||
|
|
||||||
|
after := e.json(t, "/v1/article/12/seo")["canonical_url"]
|
||||||
|
if before != "https://www.journal.example/"+locFrSlug || after != "https://journal.example.com/fr/"+locFrSlug || e.stored(11).CanonicalURL != "https://other.example/de" {
|
||||||
|
t.Errorf("before %v after %v", before, after)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("og:locale is derived in a fixed order and never invented", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
siteLocale *string
|
||||||
|
lang string
|
||||||
|
explicit *string
|
||||||
|
id uint32
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{ptrText("en-GB"), "fr", ptrText("fr_FR"), 12, "fr_FR"},
|
||||||
|
{ptrText("en-GB"), "fr", ptrText("fr_CA"), 12, "fr_CA"},
|
||||||
|
{ptrText("en-GB"), "pt-BR", nil, 40, "pt_BR"},
|
||||||
|
{ptrText("en-GB"), "en", nil, 10, "en_GB"},
|
||||||
|
{ptrText("fr-CA"), "fr", nil, 12, "fr_CA"},
|
||||||
|
{ptrText("en-GB"), "de", nil, 11, ""},
|
||||||
|
{ptrText("en"), "en", nil, 10, ""},
|
||||||
|
{nil, "de", nil, 11, ""},
|
||||||
|
{ptrText("en-GB"), "fr-CA", ptrText("fr_FR"), 41, "fr_FR"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.lang+"/"+deref(c.siteLocale)+"/"+deref(c.explicit), func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.Locale = c.siteLocale })
|
||||||
|
rows := e.store.rows[locJournalID]
|
||||||
|
for index := range rows {
|
||||||
|
if rows[index].Tag == c.lang {
|
||||||
|
rows[index].SocialLocale = c.explicit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := e.json(t, "/v1/article/"+uintText(c.id)+"/seo")
|
||||||
|
|
||||||
|
if nested(body, "og")["locale"] != c.want {
|
||||||
|
t.Errorf("og.locale = %v", nested(body, "og")["locale"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("og:locale:alternate lists the other eligible versions that resolve", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
og := nested(e.json(t, "/v1/article/10/seo"), "og")
|
||||||
|
|
||||||
|
if og["locale"] != "en_GB" || !reflect.DeepEqual(og["locale_alternates"], []any{"fr_FR"}) {
|
||||||
|
t.Errorf("og = %v", og)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An unresolved og:locale is an empty string and its alternates are left out", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.Locale = nil })
|
||||||
|
|
||||||
|
og := nested(e.json(t, "/v1/article/11/seo"), "og")
|
||||||
|
|
||||||
|
if og["locale"] != "" || len(og["locale_alternates"].([]any)) != 0 {
|
||||||
|
t.Errorf("og = %v", og)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SEO fields fall back in the language of the version", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
seoTitle, seoDescription, excerpt, body string
|
||||||
|
title, titleSource string
|
||||||
|
description, descriptionSource string
|
||||||
|
skipped bool
|
||||||
|
}{
|
||||||
|
{"Mon titre", "Ma description.", "Un résumé.", "<p>Première.</p>", "Mon titre | Journal", "seo_title", "Ma description.", "seo_description", false},
|
||||||
|
{"", "", "Un résumé.", "<p>Première.</p>", "Comment nous chiffrons une rénovation | Journal", "title", "Un résumé.", "excerpt", false},
|
||||||
|
{"", "", "", "<p>Première.</p>", "Comment nous chiffrons une rénovation | Journal", "title", "Première.", "body", false},
|
||||||
|
{"", "", "", "", "Comment nous chiffrons une rénovation | Journal", "title", "", "none", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.descriptionSource, func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(12, func(a *article.Article) {
|
||||||
|
a.SEOTitle, a.SEODescription, a.Excerpt, a.Body = c.seoTitle, c.seoDescription, c.excerpt, c.body
|
||||||
|
})
|
||||||
|
|
||||||
|
body := e.json(t, "/v1/article/12/seo")
|
||||||
|
|
||||||
|
fallbacks := nested(body, "fallbacks")
|
||||||
|
if body["title"] != c.title || fallbacks["title"] != c.titleSource || body["description"] != c.description || fallbacks["description"] != c.descriptionSource || fallbacks["site_text_skipped"] != c.skipped {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Site default text applies only to articles in its own language", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
id uint32
|
||||||
|
description string
|
||||||
|
source string
|
||||||
|
skipped bool
|
||||||
|
}{
|
||||||
|
{10, "Renovation notes.", "site", false},
|
||||||
|
{12, "", "none", true},
|
||||||
|
{30, "Renovation notes.", "site", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(uintText(c.id), func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(c.id, func(a *article.Article) { a.Body = "" })
|
||||||
|
|
||||||
|
body := e.json(t, "/v1/article/"+uintText(c.id)+"/seo")
|
||||||
|
|
||||||
|
fallbacks := nested(body, "fallbacks")
|
||||||
|
if body["description"] != c.description || fallbacks["description"] != c.source || fallbacks["site_text_skipped"] != c.skipped {
|
||||||
|
t.Errorf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A site without a default description withholds nothing", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.DefaultDescription = "" })
|
||||||
|
e.change(12, func(a *article.Article) { a.Body = "" })
|
||||||
|
|
||||||
|
fallbacks := nested(e.json(t, "/v1/article/12/seo"), "fallbacks")
|
||||||
|
|
||||||
|
if fallbacks["description"] != "none" || fallbacks["site_text_skipped"] != false {
|
||||||
|
t.Errorf("fallbacks = %v", fallbacks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Changing the default language changes which articles may use the site text", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.change(10, func(a *article.Article) { a.Body = "" })
|
||||||
|
rows := e.store.rows[locJournalID]
|
||||||
|
for index := range rows {
|
||||||
|
rows[index].IsDefault = rows[index].Tag == "fr"
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbacks := nested(e.json(t, "/v1/article/10/seo"), "fallbacks")
|
||||||
|
|
||||||
|
if fallbacks["site_text_skipped"] != true {
|
||||||
|
t.Errorf("fallbacks = %v", fallbacks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The SEO read stays idempotent and public", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
counted := newMockSeoOutArticleRepo(e.articles)
|
||||||
|
e.container.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images, e.store)
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Mount("/v1", api.InitBackendRoutes(e.container, e.settings))
|
||||||
|
e.router = router
|
||||||
|
|
||||||
|
first := e.get(t, "/v1/article/12/seo", nil)
|
||||||
|
second := e.get(t, "/v1/article/12/seo", nil)
|
||||||
|
draft := e.get(t, "/v1/article/13/seo", nil)
|
||||||
|
malformed := e.get(t, "/v1/article/abc/seo", nil)
|
||||||
|
|
||||||
|
expectStatusAndText(t, draft, 404, apperr.ArticleNotFoundErrorMessage)
|
||||||
|
expectStatusAndText(t, malformed, 400, apperr.IdUint32ErrorMessage)
|
||||||
|
if first.Body.String() != second.Body.String() || counted.writes != 0 {
|
||||||
|
t.Errorf("not idempotent or wrote: %d", counted.writes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizationSitemap(t *testing.T) {
|
||||||
|
t.Run("Each eligible version is listed once", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
|
||||||
|
rec := e.get(t, "/v1/site/journal/sitemap", nil)
|
||||||
|
|
||||||
|
var items []map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &items)
|
||||||
|
urls := map[string]string{}
|
||||||
|
for _, item := range items {
|
||||||
|
if sortedKeys(item) != "lang,modified_at,noindex,url" {
|
||||||
|
t.Errorf("keys = %s", sortedKeys(item))
|
||||||
|
}
|
||||||
|
urls[item["url"].(string)] = item["lang"].(string)
|
||||||
|
}
|
||||||
|
if urls["https://journal.example.com/fr/"+locFrSlug] != "fr" || urls["https://www.journal.example/legacy-note"] != "" {
|
||||||
|
t.Errorf("urls = %v", urls)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "cuanto-cuesta") || strings.Contains(rec.Body.String(), "takalif") || strings.Contains(rec.Body.String(), "winter") {
|
||||||
|
t.Errorf("an ineligible version is listed: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Header().Get("X-Total-Count") != uintText(uint32(len(items))) {
|
||||||
|
t.Errorf("total %q for %d items", rec.Header().Get("X-Total-Count"), len(items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Without a pattern the legacy address is kept", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = nil })
|
||||||
|
|
||||||
|
if body := e.get(t, "/v1/site/journal/sitemap", nil).Body.String(); !strings.Contains(body, "https://www.journal.example/spring") {
|
||||||
|
t.Errorf("body = %s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrText(value string) *string { return &value }
|
||||||
|
|
||||||
|
func deref(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return "null"
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func uintText(id uint32) string { return strconv.FormatUint(uint64(id), 10) }
|
||||||
|
|
||||||
|
func TestLocalizationMediaUsage(t *testing.T) {
|
||||||
|
seed := func(e *locPublicEnv) {
|
||||||
|
e.seedBanner(7, "bathroom.jpg", "hash-bathroom")
|
||||||
|
for _, id := range []uint32{10, 11, 12} {
|
||||||
|
e.updateArticle(id, func(a *article.Article) { a.CoverImageID = idPointer(7) })
|
||||||
|
}
|
||||||
|
e.updateArticle(12, func(a *article.Article) { a.Body = e.bodyUsing(7) })
|
||||||
|
e.updateArticle(20, func(a *article.Article) { a.Body = e.bodyUsing(7) })
|
||||||
|
e.updateArticle(30, func(a *article.Article) { a.Body = e.bodyUsing(7) })
|
||||||
|
e.authors.seed(3, "Camille", "camille")
|
||||||
|
avatar := e.authors.authors[3]
|
||||||
|
avatar.AvatarID = idPointer(7)
|
||||||
|
e.authors.authors[3] = avatar
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.DefaultSocialImageID = idPointer(7) })
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Article usage items carry lang and story_id", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
seed(e)
|
||||||
|
|
||||||
|
items := e.usage(t, 7)
|
||||||
|
|
||||||
|
ids := []float64{}
|
||||||
|
for _, item := range items {
|
||||||
|
if item["type"] == "article" {
|
||||||
|
ids = append(ids, item["id"].(float64))
|
||||||
|
if _, ok := item["story_id"]; !ok {
|
||||||
|
t.Errorf("story_id missing: %v", item)
|
||||||
|
}
|
||||||
|
} else if _, has := item["lang"]; has {
|
||||||
|
t.Errorf("non-article item carries lang: %v", item)
|
||||||
|
} else if _, has := item["story_id"]; has {
|
||||||
|
t.Errorf("non-article item carries story_id: %v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := map[string]any{"type": "article", "id": float64(12), "title": "Comment nous chiffrons une rénovation", "published": true, "fields": []any{"body", "cover"}, "story_id": float64(5), "lang": "fr"}
|
||||||
|
if !reflect.DeepEqual(ids, []float64{10, 11, 12, 20, 30}) || !reflect.DeepEqual(items[2], want) {
|
||||||
|
t.Errorf("ids %v item %v", ids, items[2])
|
||||||
|
}
|
||||||
|
if items[3]["story_id"] != float64(6) || items[3]["lang"] != "en" || items[4]["lang"] != "" {
|
||||||
|
t.Errorf("items = %v", items[3:])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A version using another image as social image does not list the social field", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
seed(e)
|
||||||
|
e.seedBanner(8, "kitchen.jpg", "hash-kitchen")
|
||||||
|
e.updateArticle(10, func(a *article.Article) { a.SocialImageID = idPointer(8) })
|
||||||
|
|
||||||
|
items := e.usage(t, 7)
|
||||||
|
|
||||||
|
if fields := items[0]["fields"]; !reflect.DeepEqual(fields, []any{"cover"}) {
|
||||||
|
t.Errorf("fields = %v", fields)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An author lookup failure is a generic 500", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
seed(e)
|
||||||
|
e.authors.fail.failNext("FindByAvatar")
|
||||||
|
|
||||||
|
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/7/usage", "")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An image used by 3 versions of one story is one use", func(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
seed(e)
|
||||||
|
|
||||||
|
if used := e.usedIn(t, 7); used != 5 {
|
||||||
|
t.Errorf("used_in = %d", used)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizationAlternatesWithoutPattern(t *testing.T) {
|
||||||
|
e := newLocPublicEnv(t)
|
||||||
|
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = nil })
|
||||||
|
|
||||||
|
rec := e.get(t, locFrBase, nil)
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), `"url":null`) {
|
||||||
|
t.Errorf("body = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ func TestMCPToolsMapToTheAPIWithTheKey(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
}{
|
}{
|
||||||
{"list_articles", map[string]any{"page": 2, "size": 5, "status": "draft", "q": "tile"}, "GET", "/v1/article/?page=2&q=tile&size=5&status=draft", ""},
|
{"list_articles", map[string]any{"page": 2, "size": 5, "status": "draft", "q": "tile"}, "GET", "/v1/article/?page=2&q=tile&size=5&status=draft", ""},
|
||||||
|
{"list_articles", map[string]any{"lang": "pt-BR"}, "GET", "/v1/article/?lang=pt-BR", ""},
|
||||||
{"get_article", map[string]any{"id": 7, "format": "html"}, "GET", "/v1/article/7?format=html", ""},
|
{"get_article", map[string]any{"id": 7, "format": "html"}, "GET", "/v1/article/7?format=html", ""},
|
||||||
{"create_article", map[string]any{"title": "Grout tips", "body": "<p>x</p>"}, "POST", "/v1/article/", `{"body":"<p>x</p>","title":"Grout tips"}`},
|
{"create_article", map[string]any{"title": "Grout tips", "body": "<p>x</p>"}, "POST", "/v1/article/", `{"body":"<p>x</p>","title":"Grout tips"}`},
|
||||||
{"update_article_body", map[string]any{"id": 7, "body": "<p>y</p>"}, "PATCH", "/v1/article/7", `{"body":"<p>y</p>"}`},
|
{"update_article_body", map[string]any{"id": 7, "body": "<p>y</p>"}, "PATCH", "/v1/article/7", `{"body":"<p>y</p>"}`},
|
||||||
@@ -187,3 +188,17 @@ func sameJSONBody(got string, want string) bool {
|
|||||||
}
|
}
|
||||||
return reflect.DeepEqual(a, b)
|
return reflect.DeepEqual(a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMCPToolsNoLongerSayATitleCannotChange(t *testing.T) {
|
||||||
|
f := newMCPFixture(t, false, echoStatus(200, `{"ok":true}`))
|
||||||
|
|
||||||
|
tools, err := f.session.ListTools(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schemas, _ := json.Marshal(tools.Tools)
|
||||||
|
if strings.Contains(string(schemas), "cannot be changed") {
|
||||||
|
t.Errorf("a tool still says that a title cannot be changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -424,7 +424,7 @@ func TestMediaFieldTypeLibrary(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK || len(items) != 5 {
|
if rec.Code != http.StatusOK || len(items) != 5 {
|
||||||
t.Fatalf("status %d items %v", rec.Code, items)
|
t.Fatalf("status %d items %v", rec.Code, items)
|
||||||
}
|
}
|
||||||
wantArticle := map[string]any{"type": "article", "id": float64(12), "title": "Heat pumps", "published": true, "fields": []any{"cover"}}
|
wantArticle := map[string]any{"type": "article", "id": float64(12), "title": "Heat pumps", "published": true, "fields": []any{"cover"}, "story_id": float64(0), "lang": ""}
|
||||||
if !reflect.DeepEqual(items[0], wantArticle) {
|
if !reflect.DeepEqual(items[0], wantArticle) {
|
||||||
t.Errorf("article item %v", items[0])
|
t.Errorf("article item %v", items[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -737,7 +737,7 @@ func TestMediaUsage(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
want := `[{"type": "article", "id": 1, "title": "Kitchen Renovation", "published": true, "fields": ["body"]}, {"type": "article", "id": 2, "title": "Backyard Notes", "published": false, "fields": ["body"]}]`
|
want := `[{"type": "article", "id": 1, "title": "Kitchen Renovation", "published": true, "fields": ["body"], "story_id": 0, "lang": ""}, {"type": "article", "id": 2, "title": "Backyard Notes", "published": false, "fields": ["body"], "story_id": 0, "lang": ""}]`
|
||||||
expectJSONEqual(t, rec.Body.String(), want)
|
expectJSONEqual(t, rec.Body.String(), want)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -806,6 +806,22 @@ func TestMediaUsage(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMediaDetailRepositoryFailures(t *testing.T) {
|
||||||
|
for _, method := range []string{"FindByBodyContaining", "FindByImage"} {
|
||||||
|
t.Run(method+" failing is a generic 500", func(t *testing.T) {
|
||||||
|
env := newMediaEnv(t)
|
||||||
|
seedListingLibrary(env)
|
||||||
|
env.articles.fail.failNext(method)
|
||||||
|
|
||||||
|
rec := env.send(t, http.MethodGet, "/v1/media/1", "", mediaVerifiedUserId)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status 500, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func expectJSONEqual(t *testing.T, got string, want string) {
|
func expectJSONEqual(t *testing.T, got string, want string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var gotValue, wantValue any
|
var gotValue, wantValue any
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ func TestContentScopeArticleUseCases(t *testing.T) {
|
|||||||
t.Run("The SEO check and resolve follow the read visibility", func(t *testing.T) {
|
t.Run("The SEO check and resolve follow the read visibility", func(t *testing.T) {
|
||||||
repo, sites, _, images := newRepos()
|
repo, sites, _, images := newRepos()
|
||||||
check := useCases.NewCheckArticleSEOUseCase(repo, sites, images)
|
check := useCases.NewCheckArticleSEOUseCase(repo, sites, images)
|
||||||
resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images)
|
resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images, plainLocalization())
|
||||||
reader := callerWith(22, "content.read=own")
|
reader := callerWith(22, "content.read=own")
|
||||||
if _, err := check.Check(16, reader, useCases.CheckArticleSEOCommand{}); err != nil {
|
if _, err := check.Check(16, reader, useCases.CheckArticleSEOCommand{}); err != nil {
|
||||||
t.Errorf("own draft: %v", err)
|
t.Errorf("own draft: %v", err)
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ func (w *contentScopeWorld) wire() {
|
|||||||
c.CheckPermissionUseCase = useCases.NewCheckPermissionUseCase(w.roleRepo)
|
c.CheckPermissionUseCase = useCases.NewCheckPermissionUseCase(w.roleRepo)
|
||||||
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo)
|
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo)
|
||||||
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
|
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
|
||||||
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
|
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images, plainLocalization())
|
||||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
|
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
|
||||||
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
|
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
|
||||||
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
|
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ func TestSeoDataMediaUsage(t *testing.T) {
|
|||||||
|
|
||||||
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/5/usage", "")
|
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/5/usage", "")
|
||||||
|
|
||||||
want := `[{"type": "article", "id": 3, "title": "Body article", "published": true, "fields": ["body"]},
|
want := `[{"type": "article", "id": 3, "title": "Body article", "published": true, "fields": ["body"], "story_id": 3, "lang": ""},
|
||||||
{"type": "article", "id": 8, "title": "Cover article", "published": false, "fields": ["cover"]},
|
{"type": "article", "id": 8, "title": "Cover article", "published": false, "fields": ["cover"], "story_id": 8, "lang": ""},
|
||||||
{"type": "author", "id": 2, "title": "Camille", "fields": ["avatar"]},
|
{"type": "author", "id": 2, "title": "Camille", "fields": ["avatar"]},
|
||||||
{"type": "site", "id": 1, "title": "Main", "fields": ["default_social_image"]}]`
|
{"type": "site", "id": 1, "title": "Main", "fields": ["default_social_image"]}]`
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -204,7 +204,7 @@ func TestSeoDataMediaUsage(t *testing.T) {
|
|||||||
for key := range items[0] {
|
for key := range items[0] {
|
||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
if len(items) != 1 || items[0]["id"] != float64(3) || items[0]["title"] != "Body article" || items[0]["published"] != true || len(keys) != 5 {
|
if len(items) != 1 || items[0]["id"] != float64(3) || items[0]["title"] != "Body article" || items[0]["published"] != true || len(keys) != 7 {
|
||||||
t.Errorf("item = %v", items)
|
t.Errorf("item = %v", items)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ func TestSeoOutputResolver(t *testing.T) {
|
|||||||
}
|
}
|
||||||
object := decodeSeoObject(t, rec)
|
object := decodeSeoObject(t, rec)
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"": "authors,canonical_url,description,fallbacks,image,json_ld,modified_at,og,published_at,robots,title,twitter",
|
"": "alternates,authors,canonical_url,description,fallbacks,image,json_ld,lang,modified_at,og,published_at,robots,title,twitter",
|
||||||
"og": "description,image,image_alt,locale,site_name,title,type,url",
|
"og": "description,image,image_alt,locale,locale_alternates,site_name,title,type,url",
|
||||||
"twitter": "card,description,image,image_alt,title",
|
"twitter": "card,description,image,image_alt,title",
|
||||||
"image": "alt,source,url",
|
"image": "alt,source,url",
|
||||||
"fallbacks": "canonical,description,image,title",
|
"fallbacks": "canonical,description,image,site_text_skipped,title",
|
||||||
}
|
}
|
||||||
for key, want := range cases {
|
for key, want := range cases {
|
||||||
target := object
|
target := object
|
||||||
@@ -49,9 +49,9 @@ func TestSeoOutputResolver(t *testing.T) {
|
|||||||
want := map[string]any{
|
want := map[string]any{
|
||||||
"title": title, "description": "We price by the square metre.", "canonical_url": url, "robots": "index, follow",
|
"title": title, "description": "We price by the square metre.", "canonical_url": url, "robots": "index, follow",
|
||||||
"image": map[string]any{"url": image, "alt": "", "source": "site"},
|
"image": map[string]any{"url": image, "alt": "", "source": "site"},
|
||||||
"og": map[string]any{"type": "article", "title": title, "description": "We price by the square metre.", "image": image, "image_alt": "", "url": url, "site_name": "Renew", "locale": "en"},
|
"og": map[string]any{"type": "article", "title": title, "description": "We price by the square metre.", "image": image, "image_alt": "", "url": url, "site_name": "Renew", "locale": "en", "locale_alternates": []any{}},
|
||||||
"twitter": map[string]any{"card": "summary_large_image", "title": title, "description": "We price by the square metre.", "image": image, "image_alt": ""},
|
"twitter": map[string]any{"card": "summary_large_image", "title": title, "description": "We price by the square metre.", "image": image, "image_alt": ""},
|
||||||
"fallbacks": map[string]any{"title": "title", "description": "body", "image": "site", "canonical": "site"},
|
"fallbacks": map[string]any{"title": "title", "description": "body", "image": "site", "canonical": "site", "site_text_skipped": false},
|
||||||
"published_at": "2026-09-01T08:00:00Z", "modified_at": "2026-09-10T09:30:00Z",
|
"published_at": "2026-09-01T08:00:00Z", "modified_at": "2026-09-10T09:30:00Z",
|
||||||
}
|
}
|
||||||
for key, expected := range want {
|
for key, expected := range want {
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ func TestSeoOutputSitemap(t *testing.T) {
|
|||||||
e.articles.articles[20] = article.Article{ID: 20, Title: "Twenty", IsOnline: true, SiteID: seoOutJournalID, Slug: "twenty", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified}
|
e.articles.articles[20] = article.Article{ID: 20, Title: "Twenty", IsOnline: true, SiteID: seoOutJournalID, Slug: "twenty", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified}
|
||||||
rec, items := e.sitemap(t, "", "")
|
rec, items := e.sitemap(t, "", "")
|
||||||
want := []map[string]any{
|
want := []map[string]any{
|
||||||
{"url": seoOutBase + "/" + seoOutArticleSlug, "modified_at": "2026-09-10T09:30:00Z", "noindex": false},
|
{"url": seoOutBase + "/" + seoOutArticleSlug, "modified_at": "2026-09-10T09:30:00Z", "noindex": false, "lang": ""},
|
||||||
{"url": seoOutBase + "/sixteen", "modified_at": "2026-09-10T09:30:00Z", "noindex": false},
|
{"url": seoOutBase + "/sixteen", "modified_at": "2026-09-10T09:30:00Z", "noindex": false, "lang": ""},
|
||||||
}
|
}
|
||||||
if rec.Code != http.StatusOK || !reflect.DeepEqual(items, want) || rec.Header().Get("X-Total-Count") != "2" {
|
if rec.Code != http.StatusOK || !reflect.DeepEqual(items, want) || rec.Header().Get("X-Total-Count") != "2" {
|
||||||
t.Errorf("status %d total %q items %v", rec.Code, rec.Header().Get("X-Total-Count"), items)
|
t.Errorf("status %d total %q items %v", rec.Code, rec.Header().Get("X-Total-Count"), items)
|
||||||
@@ -97,7 +97,7 @@ func TestSeoOutputSitemap(t *testing.T) {
|
|||||||
t.Run("Each item has exactly the documented keys", func(t *testing.T) {
|
t.Run("Each item has exactly the documented keys", func(t *testing.T) {
|
||||||
e := newSeoOutEnv(t)
|
e := newSeoOutEnv(t)
|
||||||
_, items := e.sitemap(t, "", "")
|
_, items := e.sitemap(t, "", "")
|
||||||
if sortedKeys(items[0]) != "modified_at,noindex,url" {
|
if sortedKeys(items[0]) != "lang,modified_at,noindex,url" {
|
||||||
t.Errorf("keys = %s", sortedKeys(items[0]))
|
t.Errorf("keys = %s", sortedKeys(items[0]))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ func newSeoOutEnv(t *testing.T) *seoOutEnv {
|
|||||||
e.sites.sites[seoOutJournalID] = journal
|
e.sites.sites[seoOutJournalID] = journal
|
||||||
e.seedBanner(seoOutSiteImage, "site.png", "hash-site")
|
e.seedBanner(seoOutSiteImage, "site.png", "hash-site")
|
||||||
c := e.container
|
c := e.container
|
||||||
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images)
|
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images, plainLocalization())
|
||||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images)
|
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images)
|
||||||
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites)
|
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites, plainLocalization())
|
||||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites, plainLocalization())
|
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites, plainLocalization())
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
|
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
|
||||||
|
|||||||
@@ -106,15 +106,15 @@ func TestSiteLanguagesReading(t *testing.T) {
|
|||||||
t.Errorf("entry = %+v", entry)
|
t.Errorf("entry = %+v", entry)
|
||||||
}
|
}
|
||||||
row := localization.Row{Tag: entry.Lang, SocialLocale: entry.SocialLocale}
|
row := localization.Row{Tag: entry.Lang, SocialLocale: entry.SocialLocale}
|
||||||
if !sameText(localization.EffectiveSocialLocale(row), c.effective) {
|
if !sameText(localization.EffectiveSocialLocale(row, nil), c.effective) {
|
||||||
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row))
|
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row, nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Only the explicit value and the tag's own region resolve the social locale", func(t *testing.T) {
|
t.Run("A bare tag borrows the region of Site.Locale", func(t *testing.T) {
|
||||||
e := newLocalizationEnv(t)
|
e := newLocalizationEnv(t)
|
||||||
e.sites.seed(5, "Atelier", "atelier", false)
|
e.sites.seed(5, "Atelier", "atelier", false)
|
||||||
gb := "en-GB"
|
gb := "en-GB"
|
||||||
@@ -125,7 +125,27 @@ func TestSiteLanguagesReading(t *testing.T) {
|
|||||||
|
|
||||||
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/atelier/languages", ""))
|
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/atelier/languages", ""))
|
||||||
|
|
||||||
if view.Languages[0].State != "not_set" {
|
if view.Languages[0].State != "derived" {
|
||||||
|
t.Errorf("state = %q", view.Languages[0].State)
|
||||||
|
}
|
||||||
|
row := localization.Row{Tag: "en"}
|
||||||
|
if !sameText(localization.EffectiveSocialLocale(row, &gb), localeOf("en_GB")) {
|
||||||
|
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row, &gb))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("No region anywhere stays not set", func(t *testing.T) {
|
||||||
|
e := newLocalizationEnv(t)
|
||||||
|
e.sites.seed(5, "Atelier", "atelier", false)
|
||||||
|
bare := "en"
|
||||||
|
stored := e.sites.sites[5]
|
||||||
|
stored.Locale = &bare
|
||||||
|
e.sites.sites[5] = stored
|
||||||
|
e.seedRows(5, localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true})
|
||||||
|
|
||||||
|
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/atelier/languages", ""))
|
||||||
|
|
||||||
|
if view.Languages[0].State != "not_set" || localization.EffectiveSocialLocale(localization.Row{Tag: "en"}, &bare) != nil {
|
||||||
t.Errorf("state = %q", view.Languages[0].State)
|
t.Errorf("state = %q", view.Languages[0].State)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,12 +60,19 @@ function formatDetailDate(iso: string, relative = false) {
|
|||||||
|
|
||||||
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
|
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
|
||||||
const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true))
|
const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true))
|
||||||
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
|
const carriesLanguage = computed(() => props.multilingual && Boolean(props.article.lang))
|
||||||
|
const publicUrl = computed(() =>
|
||||||
|
carriesLanguage.value
|
||||||
|
? `/v1/article/${props.article.id}?lang=${props.article.lang}`
|
||||||
|
: `/v1/article/${props.article.id}`,
|
||||||
|
)
|
||||||
const imagesCount = computed(() => bodyImageCount(props.article.body))
|
const imagesCount = computed(() => bodyImageCount(props.article.body))
|
||||||
const apiNote = computed(() =>
|
const apiNote = computed(() =>
|
||||||
props.article.is_online
|
carriesLanguage.value
|
||||||
? t('Live. Your website can read this article.')
|
? t('Each language version has its own address. The URL carries the language.')
|
||||||
: t('Returns 404 until the article is online.'),
|
: props.article.is_online
|
||||||
|
? t('Live. Your website can read this article.')
|
||||||
|
: t('Returns 404 until the article is online.'),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function copyUrl() {
|
async function copyUrl() {
|
||||||
@@ -77,12 +84,14 @@ async function copyUrl() {
|
|||||||
color: 'success',
|
color: 'success',
|
||||||
icon: 'i-lucide-circle-check',
|
icon: 'i-lucide-circle-check',
|
||||||
get title() {
|
get title() {
|
||||||
return t('Link copied')
|
return carriesLanguage.value ? t('API URL copied') : t('Link copied')
|
||||||
},
|
},
|
||||||
get description() {
|
get description() {
|
||||||
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
|
return carriesLanguage.value
|
||||||
props.article.title,
|
? publicUrl.value
|
||||||
])
|
: t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
|
||||||
|
props.article.title,
|
||||||
|
])
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
emit('copy-url')
|
emit('copy-url')
|
||||||
@@ -198,6 +207,9 @@ async function copyUrl() {
|
|||||||
@click="copyUrl"
|
@click="copyUrl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="carriesLanguage" class="text-xs/[18px] text-muted">
|
||||||
|
{{ apiNote }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||||
|
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
|
||||||
|
import LocaleInput from '@/components/localization/LocaleInput.vue'
|
||||||
|
import { SEO_LIMITS, normalizeSlug } from '@/lib/articleSeo'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
language: string
|
||||||
|
slug: string
|
||||||
|
published: boolean
|
||||||
|
pending: boolean
|
||||||
|
error: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: []; confirm: [slug: string] }>()
|
||||||
|
|
||||||
|
const value = ref(props.slug)
|
||||||
|
const field = ref<HTMLElement | null>(null)
|
||||||
|
const unchanged = computed(() => normalizeSlug(value.value) === props.slug)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
field.value?.querySelector('input')?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
emit('confirm', normalizeSlug(value.value))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LocaleDialog
|
||||||
|
:title="t('Edit slug for the {0} version', [language])"
|
||||||
|
:description="t('The address changes for {0} only.', [language])"
|
||||||
|
:confirm-label="t('Change address')"
|
||||||
|
:confirm-disabled="unchanged || value.trim() === ''"
|
||||||
|
:pending="pending"
|
||||||
|
@close="emit('close')"
|
||||||
|
@confirm="confirm"
|
||||||
|
>
|
||||||
|
<div ref="field" class="flex flex-col gap-1.5">
|
||||||
|
<label for="edit-slug-input" class="font-medium text-highlighted">{{
|
||||||
|
t('Address (slug)')
|
||||||
|
}}</label>
|
||||||
|
<LocaleInput
|
||||||
|
id="edit-slug-input"
|
||||||
|
v-model="value"
|
||||||
|
mono
|
||||||
|
:invalid="error !== ''"
|
||||||
|
:maxlength="SEO_LIMITS.slug"
|
||||||
|
:aria-label="t('Address (slug)')"
|
||||||
|
/>
|
||||||
|
<p v-if="error" role="alert" class="flex gap-1.5 text-xs/4.5 text-error">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LocaleAlert
|
||||||
|
v-if="published"
|
||||||
|
tone="warning"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:title="t('This version is published')"
|
||||||
|
:description="
|
||||||
|
t('Links to the old address will stop working. RenewCMS does not create redirects.')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<p class="text-xs/4.5 text-muted">{{ t('Editing the title never changes the slug.') }}</p>
|
||||||
|
</LocaleDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { SECTION_TITLE } from '@/lib/apiReference'
|
||||||
|
import { PUBLIC_LOOKUP_ROUTE, PUBLIC_STORY_ROUTE } from '@/lib/publicArticleRoutes'
|
||||||
|
|
||||||
|
const CARD_CLASS = 'flex flex-col gap-3 border border-default bg-default p-4'
|
||||||
|
const TITLE_CLASS = 'font-semibold text-highlighted'
|
||||||
|
const ROUTE_CLASS = 'font-mono text-sm break-words text-highlighted'
|
||||||
|
const SAMPLE_CLASS = 'font-mono text-xs break-words text-muted'
|
||||||
|
|
||||||
|
const LOOKUP_SAMPLE =
|
||||||
|
'{ "id": 12, "lang": "fr", "story_id": 5, "slug": "…", "alternates": [{ "id": 10, "lang": "en", "story_id": 5, "slug": "…", "url": "…" }] }'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
data-anchor="languages"
|
||||||
|
aria-labelledby="h-languages"
|
||||||
|
class="flex scroll-mt-20 flex-col gap-3"
|
||||||
|
data-testid="integration-languages"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 id="h-languages" :class="SECTION_TITLE">{{ t('Languages') }}</h2>
|
||||||
|
<p class="text-pretty text-toned">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'Read one published language of an article, or resolve an article in a language. Both requests are public.',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="CARD_CLASS" data-testid="integration-lookup">
|
||||||
|
<span :class="TITLE_CLASS">{{ t('1. Exact lookup: site + language + slug') }}</span>
|
||||||
|
<span :class="ROUTE_CLASS">{{ PUBLIC_LOOKUP_ROUTE }}</span>
|
||||||
|
<span class="text-pretty text-toned">{{
|
||||||
|
t(
|
||||||
|
'Matches the published version with that slug in that language, or returns article_not_found. It never falls back, because a slug belongs to one language.',
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
<span :class="SAMPLE_CLASS">{{ LOOKUP_SAMPLE }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="CARD_CLASS" data-testid="integration-story">
|
||||||
|
<span :class="TITLE_CLASS">{{ t('2. Resolve by article group, with fallback') }}</span>
|
||||||
|
<span :class="ROUTE_CLASS">{{ PUBLIC_STORY_ROUTE }}</span>
|
||||||
|
<span class="text-pretty text-toned">{{
|
||||||
|
t(
|
||||||
|
'Use story_id from any response or from alternates[]. French published: lang "fr". French missing or unpublished with fallback=default: the published default, with "lang": "en", "requested_lang": "fr", "fallback": true. Without fallback=default, a missing French is language_not_available. No other language is tried, and drafts are never returned.',
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="CARD_CLASS" data-testid="integration-errors">
|
||||||
|
<span :class="TITLE_CLASS">{{ t('3. Errors') }}</span>
|
||||||
|
<span :class="ROUTE_CLASS"
|
||||||
|
>article_not_found · language_not_available · site_not_found ·
|
||||||
|
invalid_language</span
|
||||||
|
>
|
||||||
|
<span class="text-pretty text-toned">{{
|
||||||
|
t(
|
||||||
|
'article_not_found: no published version has that slug in that language. language_not_available: the requested language and the allowed fallback are both unavailable. site_not_found: no site has that key. invalid_language: the language is not a language tag.',
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="CARD_CLASS" data-testid="integration-unchanged">
|
||||||
|
<span :class="TITLE_CLASS">{{ t('Existing, unchanged') }}</span>
|
||||||
|
<span :class="ROUTE_CLASS">GET /v1/article/{id}</span>
|
||||||
|
<span :class="ROUTE_CLASS">PATCH /v1/article/{id}/publish</span>
|
||||||
|
<span :class="ROUTE_CLASS">Editor: /article/{id}/edit</span>
|
||||||
|
<span class="text-pretty text-toned">{{
|
||||||
|
t('Each article ID is one language version. Article-ID links keep working.')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
joinNames,
|
joinNames,
|
||||||
pluralArticles,
|
pluralArticles,
|
||||||
} from '@/lib/mediaFormat'
|
} from '@/lib/mediaFormat'
|
||||||
import { usageSplitOf } from '@/lib/mediaUsage'
|
import { usageLanguages, usageSplitOf } from '@/lib/mediaUsage'
|
||||||
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
|
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||||
import { usePermissionsStore } from '@/stores/permissions'
|
import { usePermissionsStore } from '@/stores/permissions'
|
||||||
@@ -393,12 +393,21 @@ const duplicateAlert = computed(() => {
|
|||||||
'border-t border-muted': index > 0,
|
'border-t border-muted': index > 0,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<RouterLink
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
:to="`/article/${article.id}/edit`"
|
<RouterLink
|
||||||
class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3"
|
:to="`/article/${article.id}/edit`"
|
||||||
>
|
class="truncate font-medium text-highlighted underline underline-offset-3"
|
||||||
{{ article.title }}
|
>
|
||||||
</RouterLink>
|
{{ article.title }}
|
||||||
|
</RouterLink>
|
||||||
|
<span
|
||||||
|
v-if="split.showLanguages"
|
||||||
|
data-testid="usage-languages"
|
||||||
|
class="truncate text-xs text-muted"
|
||||||
|
>
|
||||||
|
{{ usageLanguages(article) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<MediaArticleStatusBadge
|
<MediaArticleStatusBadge
|
||||||
:published="article.published"
|
:published="article.published"
|
||||||
size="md"
|
size="md"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import FallbackChip from '@/components/FallbackChip.vue'
|
|||||||
import SegmentedTabs from '@/components/SegmentedTabs.vue'
|
import SegmentedTabs from '@/components/SegmentedTabs.vue'
|
||||||
import type { Site } from '@/composables/useSites'
|
import type { Site } from '@/composables/useSites'
|
||||||
import { hostOf } from '@/lib/directory'
|
import { hostOf } from '@/lib/directory'
|
||||||
|
import { breadcrumbOf, descriptionHelp, type LocalizedSeoContext } from '@/lib/localizedSeo'
|
||||||
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||||
import {
|
import {
|
||||||
PREVIEW_LIMITS,
|
PREVIEW_LIMITS,
|
||||||
@@ -26,6 +27,7 @@ const props = defineProps<{
|
|||||||
input: SeoPreviewInput
|
input: SeoPreviewInput
|
||||||
site: Site | undefined
|
site: Site | undefined
|
||||||
slug: string
|
slug: string
|
||||||
|
localized?: LocalizedSeoContext | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const device = defineModel<PreviewDevice>('device', { default: 'desktop' })
|
const device = defineModel<PreviewDevice>('device', { default: 'desktop' })
|
||||||
@@ -36,13 +38,35 @@ const items = computed(() => [
|
|||||||
])
|
])
|
||||||
const fullTitle = computed(() => previewTitle(props.input))
|
const fullTitle = computed(() => previewTitle(props.input))
|
||||||
const shownTitle = computed(() => truncateText(fullTitle.value, PREVIEW_LIMITS[device.value].title))
|
const shownTitle = computed(() => truncateText(fullTitle.value, PREVIEW_LIMITS[device.value].title))
|
||||||
|
const withheldSiteText = computed(
|
||||||
|
() =>
|
||||||
|
props.localized?.resolved?.fallbacks.site_text_skipped === true &&
|
||||||
|
previewDescriptionSource(props.input) === 'site',
|
||||||
|
)
|
||||||
const shownDescription = computed(() =>
|
const shownDescription = computed(() =>
|
||||||
truncateText(previewDescription(props.input), PREVIEW_LIMITS[device.value].description),
|
truncateText(
|
||||||
|
withheldSiteText.value ? '' : previewDescription(props.input),
|
||||||
|
PREVIEW_LIMITS[device.value].description,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const cutOff = computed(() => titleIsCut(fullTitle.value, device.value))
|
const cutOff = computed(() => titleIsCut(fullTitle.value, device.value))
|
||||||
const siteName = computed(() => siteDisplayName(props.site))
|
const siteName = computed(() => siteDisplayName(props.site))
|
||||||
const siteInitial = computed(() => [...siteName.value][0] ?? '')
|
const siteInitial = computed(() => [...siteName.value][0] ?? '')
|
||||||
const host = computed(() => hostOf(props.site?.base_url ?? ''))
|
const host = computed(() => hostOf(props.site?.base_url ?? ''))
|
||||||
|
const localizedAddress = computed(() => {
|
||||||
|
const resolved = props.localized?.resolved
|
||||||
|
return props.localized?.pattern && resolved ? breadcrumbOf(resolved.canonical_url) : ''
|
||||||
|
})
|
||||||
|
const addressLine = computed(
|
||||||
|
() => localizedAddress.value || `${host.value} › ${props.slug.slice(0, SLUG_PATH_LIMIT)}`,
|
||||||
|
)
|
||||||
|
const localizedNote = computed(() =>
|
||||||
|
props.localized
|
||||||
|
? t('Shows the localized address and canonical. Description: {0}.', [
|
||||||
|
descriptionHelp(props.localized).replace(/\.$/, ''),
|
||||||
|
])
|
||||||
|
: '',
|
||||||
|
)
|
||||||
const chips = computed(() => {
|
const chips = computed(() => {
|
||||||
const titleChip =
|
const titleChip =
|
||||||
previewTitleSource(props.input) === 'title' ? [chipLabel('article_title')] : []
|
previewTitleSource(props.input) === 'title' ? [chipLabel('article_title')] : []
|
||||||
@@ -54,7 +78,9 @@ const chips = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2" data-testid="search-preview">
|
<div class="flex flex-col gap-2" data-testid="search-preview">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="font-semibold text-highlighted">{{ t('Search result') }}</span>
|
<span class="font-semibold text-highlighted">{{
|
||||||
|
localized ? t('How it can appear in search') : t('Search result')
|
||||||
|
}}</span>
|
||||||
<div class="w-37.5">
|
<div class="w-37.5">
|
||||||
<SegmentedTabs v-model="device" compact :items="items" />
|
<SegmentedTabs v-model="device" compact :items="items" />
|
||||||
</div>
|
</div>
|
||||||
@@ -75,9 +101,7 @@ const chips = computed(() => {
|
|||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col">
|
||||||
<span class="text-xs/4 text-highlighted">{{ siteName }}</span>
|
<span class="text-xs/4 text-highlighted">{{ siteName }}</span>
|
||||||
<span class="truncate text-2xs/3.5 text-muted"
|
<span class="truncate text-2xs/3.5 text-muted">{{ addressLine }}</span>
|
||||||
>{{ host }} › {{ slug.slice(0, SLUG_PATH_LIMIT) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -92,7 +116,10 @@ const chips = computed(() => {
|
|||||||
>{{ shownDescription }}</span
|
>{{ shownDescription }}</span
|
||||||
>
|
>
|
||||||
</UCard>
|
</UCard>
|
||||||
<div v-if="chips.length > 0" class="flex flex-wrap gap-1.5">
|
<p v-if="localized" class="text-xs/4.5 text-muted" data-testid="search-preview-note">
|
||||||
|
{{ localizedNote }}
|
||||||
|
</p>
|
||||||
|
<div v-if="chips.length > 0 && !localized" class="flex flex-wrap gap-1.5">
|
||||||
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
|
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
|
||||||
</div>
|
</div>
|
||||||
<span v-if="cutOff" class="text-xs text-muted" data-testid="search-preview-cut">{{
|
<span v-if="cutOff" class="text-xs text-muted" data-testid="search-preview-cut">{{
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||||
|
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
|
||||||
|
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||||
|
import {
|
||||||
|
canonicalNote,
|
||||||
|
disabledNote,
|
||||||
|
socialLocaleLine,
|
||||||
|
socialLocaleNote,
|
||||||
|
type LocalizedSeoContext,
|
||||||
|
} from '@/lib/localizedSeo'
|
||||||
|
import { nameInSentence } from '@/lib/storyList'
|
||||||
|
|
||||||
|
const props = defineProps<{ context: LocalizedSeoContext }>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const CARD_CLASS = 'flex flex-col gap-3 border border-default bg-default p-4'
|
||||||
|
const NOTE_CLASS = 'text-xs/4.5 text-pretty text-muted'
|
||||||
|
|
||||||
|
const configured = computed(() => props.context.pattern !== null)
|
||||||
|
const canonical = computed(() => props.context.resolved?.canonical_url ?? '')
|
||||||
|
const alternates = computed(() => props.context.resolved?.alternates ?? [])
|
||||||
|
const hasLocale = computed(() => Boolean(props.context.resolved?.og.locale))
|
||||||
|
|
||||||
|
function openPublicUrl() {
|
||||||
|
router.push(`/settings/sites/${props.context.siteKey}/public-url`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4" data-testid="seo-language-cards">
|
||||||
|
<LocaleAlert
|
||||||
|
v-if="!configured"
|
||||||
|
tone="info"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
:title="t('Public URL not configured')"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
'Add a public URL pattern for {0} to show page and canonical addresses. API links by article ID still work.',
|
||||||
|
[context.siteName],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<LocaleButton small @click="openPublicUrl">
|
||||||
|
{{ t('Open Public URL settings') }}
|
||||||
|
</LocaleButton>
|
||||||
|
</template>
|
||||||
|
</LocaleAlert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section :class="CARD_CLASS" data-testid="seo-canonical-card">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<LocaleBadge tone="neutral" icon="i-lucide-link">{{
|
||||||
|
t('Canonical')
|
||||||
|
}}</LocaleBadge>
|
||||||
|
<span class="font-mono text-sm break-words text-highlighted">{{
|
||||||
|
canonical
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p :class="NOTE_CLASS">{{ canonicalNote(context) }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section :class="CARD_CLASS" data-testid="seo-alternates-card">
|
||||||
|
<h3 class="font-semibold text-highlighted">{{ t('Alternate languages') }}</h3>
|
||||||
|
<p class="text-sm text-toned">
|
||||||
|
{{ t('Published versions in enabled languages only.') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="alternate in alternates"
|
||||||
|
:key="alternate.id"
|
||||||
|
class="flex items-center gap-2.5"
|
||||||
|
data-testid="seo-alternate-row"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-mono text-sm uppercase"
|
||||||
|
:aria-label="nameInSentence(alternate.lang)"
|
||||||
|
>{{ alternate.lang }}</span
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm text-toned">{{
|
||||||
|
alternate.url ?? alternate.slug
|
||||||
|
}}</span>
|
||||||
|
<LocaleBadge tone="success" icon="i-lucide-circle-check">{{
|
||||||
|
t('Published')
|
||||||
|
}}</LocaleBadge>
|
||||||
|
</div>
|
||||||
|
<p :class="NOTE_CLASS">{{ disabledNote(context) }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<section :class="CARD_CLASS" data-testid="seo-social-card">
|
||||||
|
<h3 class="font-semibold text-highlighted">{{ t('Social locale') }}</h3>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<LocaleBadge tone="neutral" icon="i-lucide-share-2">og:locale</LocaleBadge>
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
:class="hasLocale ? 'font-mono text-highlighted' : 'text-muted'"
|
||||||
|
>{{ socialLocaleLine(context) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p :class="NOTE_CLASS">{{ socialLocaleNote(context) }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+110
-17
@@ -9,6 +9,10 @@ import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
|||||||
import FallbackChip from '@/components/FallbackChip.vue'
|
import FallbackChip from '@/components/FallbackChip.vue'
|
||||||
import MediaField from '@/components/MediaField.vue'
|
import MediaField from '@/components/MediaField.vue'
|
||||||
import SearchPreview from '@/components/SearchPreview.vue'
|
import SearchPreview from '@/components/SearchPreview.vue'
|
||||||
|
import SeoLanguageCards from '@/components/SeoLanguageCards.vue'
|
||||||
|
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
|
||||||
|
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||||
|
import LocaleInput from '@/components/localization/LocaleInput.vue'
|
||||||
import SeoAdvancedSection from '@/components/SeoAdvancedSection.vue'
|
import SeoAdvancedSection from '@/components/SeoAdvancedSection.vue'
|
||||||
import SeoSocialSection from '@/components/SeoSocialSection.vue'
|
import SeoSocialSection from '@/components/SeoSocialSection.vue'
|
||||||
import SlugField from '@/components/SlugField.vue'
|
import SlugField from '@/components/SlugField.vue'
|
||||||
@@ -22,6 +26,14 @@ import { useSeoPanelSlug } from '@/composables/useSeoPanelSlug'
|
|||||||
import type { Site } from '@/composables/useSites'
|
import type { Site } from '@/composables/useSites'
|
||||||
import { SEO_LIMITS, charCount, type ArticleSeo } from '@/lib/articleSeo'
|
import { SEO_LIMITS, charCount, type ArticleSeo } from '@/lib/articleSeo'
|
||||||
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||||
|
import {
|
||||||
|
altHelp,
|
||||||
|
descriptionBadge,
|
||||||
|
descriptionHelp,
|
||||||
|
pageLanguage,
|
||||||
|
titleHelp,
|
||||||
|
type LocalizedSeoContext,
|
||||||
|
} from '@/lib/localizedSeo'
|
||||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||||
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
|
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +73,7 @@ const props = defineProps<{
|
|||||||
saving: boolean
|
saving: boolean
|
||||||
notice: DirectoryNotice | null
|
notice: DirectoryNotice | null
|
||||||
focus: SeoFocus | null
|
focus: SeoFocus | null
|
||||||
|
localized?: LocalizedSeoContext | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -72,6 +85,7 @@ const emit = defineEmits<{
|
|||||||
go: [field: SuggestionField]
|
go: [field: SuggestionField]
|
||||||
dismiss: [code: SuggestionCode]
|
dismiss: [code: SuggestionCode]
|
||||||
restore: []
|
restore: []
|
||||||
|
'edit-slug': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const [DefineSuggestions, Suggestions] = createReusableTemplate()
|
const [DefineSuggestions, Suggestions] = createReusableTemplate()
|
||||||
@@ -85,6 +99,10 @@ const focusTarget = ref<SeoFocus | null>(props.focus)
|
|||||||
const device = ref<PreviewDevice>('desktop')
|
const device = ref<PreviewDevice>('desktop')
|
||||||
|
|
||||||
const seo = computed(() => props.modelValue)
|
const seo = computed(() => props.modelValue)
|
||||||
|
const pageName = computed(() => (props.localized ? pageLanguage(props.localized) : ''))
|
||||||
|
const panelTitle = computed(() =>
|
||||||
|
props.localized ? t('Search and sharing · {0}', [pageName.value]) : t('Search and sharing'),
|
||||||
|
)
|
||||||
const siteName = computed(() => siteDisplayName(props.site))
|
const siteName = computed(() => siteDisplayName(props.site))
|
||||||
const previewInput = computed<SeoPreviewInput>(() => ({
|
const previewInput = computed<SeoPreviewInput>(() => ({
|
||||||
seo: seo.value,
|
seo: seo.value,
|
||||||
@@ -250,6 +268,7 @@ function submit() {
|
|||||||
:input="previewInput"
|
:input="previewInput"
|
||||||
:site="site"
|
:site="site"
|
||||||
:slug="displaySlug"
|
:slug="displaySlug"
|
||||||
|
:localized="localized"
|
||||||
/>
|
/>
|
||||||
<SocialCardPreview
|
<SocialCardPreview
|
||||||
:input="previewInput"
|
:input="previewInput"
|
||||||
@@ -262,7 +281,57 @@ function submit() {
|
|||||||
|
|
||||||
<DefineFields>
|
<DefineFields>
|
||||||
<div class="flex min-w-0 flex-col gap-4.5">
|
<div class="flex min-w-0 flex-col gap-4.5">
|
||||||
<div class="flex flex-col gap-2">
|
<template v-if="localized">
|
||||||
|
<p class="text-sm text-toned" data-testid="seo-language-scope">
|
||||||
|
{{
|
||||||
|
t('Applies only to the {0} version. Page language: {1}.', [
|
||||||
|
pageName,
|
||||||
|
localized.lang,
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<SeoLanguageCards :context="localized" />
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 border border-default bg-default p-4"
|
||||||
|
data-testid="seo-address-card"
|
||||||
|
>
|
||||||
|
<DirectoryField
|
||||||
|
:label="t('Address (slug)')"
|
||||||
|
:help="
|
||||||
|
t(
|
||||||
|
'Unique within {0} and {1}. Editing the title never changes the address.',
|
||||||
|
[siteName, pageName],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<LocaleInput
|
||||||
|
:model-value="savedSlug"
|
||||||
|
mono
|
||||||
|
readonly
|
||||||
|
locked
|
||||||
|
:aria-label="t('Address (slug)')"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
<div class="flex flex-col items-start gap-1.5">
|
||||||
|
<LocaleButton icon="i-lucide-pencil" @click="emit('edit-slug')">
|
||||||
|
{{ t('Edit slug') }}
|
||||||
|
</LocaleButton>
|
||||||
|
<p class="text-xs/4.5 text-muted">
|
||||||
|
{{
|
||||||
|
t('Editing the title never changes the slug. Only Edit slug does.')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs/4.5 text-highlighted">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'Links to the old address will stop working. RenewCMS does not create redirects.',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex flex-col gap-2">
|
||||||
<SlugField
|
<SlugField
|
||||||
:label="t('URL slug')"
|
:label="t('URL slug')"
|
||||||
:mode="slugMode"
|
:mode="slugMode"
|
||||||
@@ -299,9 +368,13 @@ function submit() {
|
|||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<DirectoryField
|
<DirectoryField
|
||||||
:label="t('Meta title')"
|
:label="localized ? t('Page title') : t('Meta title')"
|
||||||
optional
|
:optional="!localized"
|
||||||
:help="t('Recommended 30 to 60 characters. This is a guide, not a limit.')"
|
:help="
|
||||||
|
localized
|
||||||
|
? titleHelp(localized)
|
||||||
|
: t('Recommended 30 to 60 characters. This is a guide, not a limit.')
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template #hint>
|
<template #hint>
|
||||||
<SoftCounter
|
<SoftCounter
|
||||||
@@ -321,14 +394,21 @@ function submit() {
|
|||||||
@update:model-value="typeText('seo_title', String($event))"
|
@update:model-value="typeText('seo_title', String($event))"
|
||||||
/>
|
/>
|
||||||
</DirectoryField>
|
</DirectoryField>
|
||||||
<FallbackChip v-if="!seo.seo_title" :label="chipLabel('article_title')" />
|
<FallbackChip
|
||||||
|
v-if="!seo.seo_title && !localized"
|
||||||
|
:label="chipLabel('article_title')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<DirectoryField
|
<DirectoryField
|
||||||
:label="t('Meta description')"
|
:label="localized ? t('Description') : t('Meta description')"
|
||||||
optional
|
:optional="!localized"
|
||||||
:help="t('Recommended 70 to 160 characters.')"
|
:help="
|
||||||
|
localized
|
||||||
|
? descriptionHelp(localized)
|
||||||
|
: t('Recommended 70 to 160 characters.')
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template #hint>
|
<template #hint>
|
||||||
<SoftCounter
|
<SoftCounter
|
||||||
@@ -348,8 +428,19 @@ function submit() {
|
|||||||
@update:model-value="typeText('seo_description', String($event))"
|
@update:model-value="typeText('seo_description', String($event))"
|
||||||
/>
|
/>
|
||||||
</DirectoryField>
|
</DirectoryField>
|
||||||
|
<LocaleBadge
|
||||||
|
v-if="localized && descriptionBadge(localized)"
|
||||||
|
:tone="localized.resolved?.fallbacks.site_text_skipped ? 'neutral' : 'info'"
|
||||||
|
:icon="
|
||||||
|
localized.resolved?.fallbacks.site_text_skipped
|
||||||
|
? 'i-lucide-minus'
|
||||||
|
: 'i-lucide-languages'
|
||||||
|
"
|
||||||
|
class="self-start"
|
||||||
|
>{{ descriptionBadge(localized) }}</LocaleBadge
|
||||||
|
>
|
||||||
<FallbackChip
|
<FallbackChip
|
||||||
v-if="descriptionChipLabel && !seo.seo_description"
|
v-else-if="descriptionChipLabel && !seo.seo_description && !localized"
|
||||||
:label="descriptionChipLabel"
|
:label="descriptionChipLabel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,14 +487,16 @@ function submit() {
|
|||||||
@update:model-value="setCover"
|
@update:model-value="setCover"
|
||||||
/>
|
/>
|
||||||
<DirectoryField
|
<DirectoryField
|
||||||
v-if="seo.cover_image_id !== null"
|
v-if="seo.cover_image_id !== null || localized"
|
||||||
:label="t('Alt text')"
|
:label="localized ? t('Image alternative text') : t('Alt text')"
|
||||||
:help="
|
:help="
|
||||||
seo.cover_image_alt
|
localized
|
||||||
? ''
|
? altHelp()
|
||||||
: t(
|
: seo.cover_image_alt
|
||||||
'Needed before the cover counts as ready. Sent as image alt in the API.',
|
? ''
|
||||||
)
|
: t(
|
||||||
|
'Needed before the cover counts as ready. Sent as image alt in the API.',
|
||||||
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<UInput
|
<UInput
|
||||||
@@ -459,7 +552,7 @@ function submit() {
|
|||||||
|
|
||||||
<DirectoryPanel
|
<DirectoryPanel
|
||||||
wide
|
wide
|
||||||
:title="t('Search and sharing')"
|
:title="panelTitle"
|
||||||
:submit-label="t('Save changes')"
|
:submit-label="t('Save changes')"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
:submit-disabled="excerptTooLong"
|
:submit-disabled="excerptTooLong"
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
import ArticleDetails from '../ArticleDetails.vue'
|
import ArticleDetails from '../ArticleDetails.vue'
|
||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
|
||||||
|
const toastAdd = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||||
|
return { ...actual, useToast: () => ({ add: toastAdd, toasts: { value: [] } }) }
|
||||||
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function mountDetails(article: Omit<Article, 'images'>) {
|
function mountDetails(article: Omit<Article, 'images'>, multilingual = false) {
|
||||||
return mount(ArticleDetails, {
|
return mount(ArticleDetails, {
|
||||||
props: { article: article as Article },
|
props: { article: article as Article, multilingual },
|
||||||
global: { plugins: [uiPlugin] },
|
global: { plugins: [uiPlugin] },
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
})
|
})
|
||||||
@@ -44,3 +51,49 @@ describe('An article from the API carries no images field', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('The public API URL of a version', () => {
|
||||||
|
const french = { ...baseArticle, id: 14, lang: 'fr' }
|
||||||
|
|
||||||
|
async function copyFrom(wrapper: ReturnType<typeof mountDetails>) {
|
||||||
|
toastAdd.mockClear()
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||||
|
Object.assign(navigator, { clipboard: { writeText } })
|
||||||
|
await wrapper.find('[aria-label="Copy the public API URL"]').trigger('click')
|
||||||
|
await Promise.resolve()
|
||||||
|
return { writeText, toast: toastAdd.mock.calls[0]?.[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('carries the language on a multilingual site', async () => {
|
||||||
|
const wrapper = mountDetails(french, true)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('/v1/article/14?lang=fr')
|
||||||
|
expect(wrapper.text()).toContain(
|
||||||
|
'Each language version has its own address. The URL carries the language.',
|
||||||
|
)
|
||||||
|
const copied = await copyFrom(wrapper)
|
||||||
|
expect(copied.writeText).toHaveBeenCalledWith(
|
||||||
|
`${window.location.origin}/v1/article/14?lang=fr`,
|
||||||
|
)
|
||||||
|
expect(copied.toast.title).toBe('API URL copied')
|
||||||
|
expect(copied.toast.description).toBe('/v1/article/14?lang=fr')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries no lang for an unassigned version', () => {
|
||||||
|
const wrapper = mountDetails({ ...baseArticle, id: 15, lang: '' }, true)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('/v1/article/15')
|
||||||
|
expect(wrapper.text()).not.toContain('?lang=')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads as before on a single-language site', async () => {
|
||||||
|
const wrapper = mountDetails(french)
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('?lang=')
|
||||||
|
const copied = await copyFrom(wrapper)
|
||||||
|
expect(copied.toast.title).toBe('Link copied')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,6 +23,7 @@ export type SaveOutcome =
|
|||||||
| 'slug-taken'
|
| 'slug-taken'
|
||||||
| 'invalid-url'
|
| 'invalid-url'
|
||||||
| 'invalid-title'
|
| 'invalid-title'
|
||||||
|
| 'invalid-slug'
|
||||||
| 'conflict'
|
| 'conflict'
|
||||||
|
|
||||||
export interface ArticleMeta {
|
export interface ArticleMeta {
|
||||||
@@ -32,6 +33,7 @@ export interface ArticleMeta {
|
|||||||
|
|
||||||
const SLUG_TAKEN_CODE = 'slug_taken'
|
const SLUG_TAKEN_CODE = 'slug_taken'
|
||||||
const INVALID_URL_CODE = 'invalid_url'
|
const INVALID_URL_CODE = 'invalid_url'
|
||||||
|
const INVALID_SLUG_CODE = 'invalid_slug'
|
||||||
const REVISION_CONFLICT_CODE = 'revision_conflict'
|
const REVISION_CONFLICT_CODE = 'revision_conflict'
|
||||||
|
|
||||||
export interface SaveOptions {
|
export interface SaveOptions {
|
||||||
@@ -194,6 +196,9 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
|
|||||||
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
|
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
|
||||||
return 'slug-taken'
|
return 'slug-taken'
|
||||||
}
|
}
|
||||||
|
if (statusOf(err) === 400 && code === INVALID_SLUG_CODE) {
|
||||||
|
return 'invalid-slug'
|
||||||
|
}
|
||||||
return statusOf(err) === 422 && code === INVALID_URL_CODE ? 'invalid-url' : null
|
return statusOf(err) === 422 && code === INVALID_URL_CODE ? 'invalid-url' : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useLocalizedToast } from '@/i18n/presentation'
|
||||||
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
import type { SaveOutcome, useArticleDraft } from '@/composables/useArticleDraft'
|
||||||
|
import type { useEditorVersions } from '@/composables/useEditorVersions'
|
||||||
|
import { useResolvedSeo } from '@/composables/useResolvedSeo'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
import type { LocalizedSeoContext } from '@/lib/localizedSeo'
|
||||||
|
|
||||||
|
interface LocalizedSeoOptions {
|
||||||
|
articleId: number
|
||||||
|
article: Ref<Article | null>
|
||||||
|
site: ComputedRef<Site | undefined>
|
||||||
|
versions: ReturnType<typeof useEditorVersions>
|
||||||
|
draft: ReturnType<typeof useArticleDraft>
|
||||||
|
panelOpen: Ref<boolean>
|
||||||
|
save: () => Promise<SaveOutcome>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorLocalizedSeo(options: LocalizedSeoOptions) {
|
||||||
|
const toast = useLocalizedToast()
|
||||||
|
const resolvedSeo = useResolvedSeo(options.articleId)
|
||||||
|
const slugDialogOpen = ref(false)
|
||||||
|
const slugPending = ref(false)
|
||||||
|
const slugOutcome = ref<SaveOutcome | null>(null)
|
||||||
|
const takenSlug = ref('')
|
||||||
|
|
||||||
|
const context = computed<LocalizedSeoContext | null>(() => {
|
||||||
|
const site = options.site.value
|
||||||
|
const lang = options.article.value?.lang ?? ''
|
||||||
|
if (!options.versions.multilingual.value || !site || !lang) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const languages = options.versions.siteLanguages.value
|
||||||
|
return {
|
||||||
|
lang,
|
||||||
|
siteName: site.site_name || site.name,
|
||||||
|
siteKey: site.key,
|
||||||
|
pattern: site.public_url_pattern ?? null,
|
||||||
|
defaultLang: languages.find((language) => language.is_default)?.lang ?? '',
|
||||||
|
disabledLangs: languages
|
||||||
|
.filter((language) => !language.enabled && language.published_count > 0)
|
||||||
|
.map((language) => language.lang),
|
||||||
|
published: options.article.value?.is_online ?? false,
|
||||||
|
socialState:
|
||||||
|
languages.find((language) => language.lang === lang)?.social_locale_state ?? '',
|
||||||
|
resolved: resolvedSeo.resolved.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => options.panelOpen.value && context.value !== null,
|
||||||
|
(wanted) => {
|
||||||
|
if (wanted) {
|
||||||
|
void resolvedSeo.load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const slugError = computed(() => {
|
||||||
|
if (slugOutcome.value === 'slug-taken') {
|
||||||
|
return t('The slug "{0}" is already used on {1}.', [
|
||||||
|
takenSlug.value,
|
||||||
|
context.value?.siteName ?? '',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return slugOutcome.value === 'invalid-slug'
|
||||||
|
? t('Lowercase letters, numbers and hyphens. Unique on {0}.', [
|
||||||
|
context.value?.siteName ?? '',
|
||||||
|
])
|
||||||
|
: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function openSlugDialog() {
|
||||||
|
slugOutcome.value = null
|
||||||
|
slugDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSlugDialog() {
|
||||||
|
slugDialogOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSlug(slug: string) {
|
||||||
|
slugPending.value = true
|
||||||
|
takenSlug.value = slug
|
||||||
|
options.draft.editSeo({ ...options.draft.seo.value, slug })
|
||||||
|
const outcome = await options.save()
|
||||||
|
slugPending.value = false
|
||||||
|
slugOutcome.value = outcome
|
||||||
|
if (outcome === 'saved') {
|
||||||
|
slugDialogOpen.value = false
|
||||||
|
void resolvedSeo.load()
|
||||||
|
toast.add(() => ({
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
get title() {
|
||||||
|
return t('Address changed')
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return t('The address of the {0} version is now {1}.', [
|
||||||
|
options.article.value?.lang ?? '',
|
||||||
|
slug,
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (outcome === 'conflict') {
|
||||||
|
slugDialogOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
slugDialogOpen,
|
||||||
|
slugPending,
|
||||||
|
slugError,
|
||||||
|
openSlugDialog,
|
||||||
|
closeSlugDialog,
|
||||||
|
confirmSlug,
|
||||||
|
reloadResolved: resolvedSeo.load,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ export interface MediaUsageItem {
|
|||||||
title?: string
|
title?: string
|
||||||
published?: boolean
|
published?: boolean
|
||||||
fields?: string[]
|
fields?: string[]
|
||||||
|
story_id?: number
|
||||||
|
lang?: string
|
||||||
content_type_key?: string
|
content_type_key?: string
|
||||||
content_type_name?: string
|
content_type_name?: string
|
||||||
first_field?: UsageFirstField
|
first_field?: UsageFirstField
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
|||||||
Articles: [
|
Articles: [
|
||||||
'GET /v1/article/',
|
'GET /v1/article/',
|
||||||
'GET /v1/article/{id}',
|
'GET /v1/article/{id}',
|
||||||
|
'GET /v1/public/site/{key}/lang/{lang}/article/{slug}',
|
||||||
|
'GET /v1/public/site/{key}/story/{story_id}',
|
||||||
'POST /v1/article/',
|
'POST /v1/article/',
|
||||||
'PATCH /v1/article/{id}',
|
'PATCH /v1/article/{id}',
|
||||||
'PATCH /v1/article/{id}/publish',
|
'PATCH /v1/article/{id}/publish',
|
||||||
@@ -243,7 +245,7 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Catalogue routes', () => {
|
describe('Catalogue routes', () => {
|
||||||
it('holds exactly the 43 key-callable routes in 9 groups, in order', () => {
|
it('holds exactly the 45 key-callable routes in 9 groups, in order', () => {
|
||||||
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
||||||
'Articles',
|
'Articles',
|
||||||
'Search and sharing',
|
'Search and sharing',
|
||||||
@@ -260,12 +262,12 @@ describe('Catalogue routes', () => {
|
|||||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||||
),
|
),
|
||||||
).toEqual(EXPECTED_GROUPS)
|
).toEqual(EXPECTED_GROUPS)
|
||||||
expect(allEndpoints()).toHaveLength(43)
|
expect(allEndpoints()).toHaveLength(45)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('counts the routes of each group', () => {
|
it('counts the routes of each group', () => {
|
||||||
expect(API_GROUPS.map((group) => group.endpoints.length)).toEqual([
|
expect(API_GROUPS.map((group) => group.endpoints.length)).toEqual([
|
||||||
8, 2, 5, 5, 2, 11, 3, 6, 1,
|
10, 2, 5, 5, 2, 11, 3, 6, 1,
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -295,7 +297,7 @@ describe('Catalogue routes', () => {
|
|||||||
),
|
),
|
||||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||||
]
|
]
|
||||||
expect(described).toHaveLength(9 + 43 + 40 + 6)
|
expect(described).toHaveLength(9 + 45 + 40 + 6)
|
||||||
const blank = described.filter(([, description]) => description.trim() === '')
|
const blank = described.filter(([, description]) => description.trim() === '')
|
||||||
expect(blank.map(([name]) => name)).toEqual([])
|
expect(blank.map(([name]) => name)).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -323,7 +325,12 @@ describe('Catalogue completeness', () => {
|
|||||||
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
|
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
|
||||||
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
|
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
|
||||||
expect(examples.curl).toContain(
|
expect(examples.curl).toContain(
|
||||||
`"${ORIGIN}${endpoint.path.replace('{key}', sampleKey(endpoint.path)).replace('{id}', String(endpoint.pathId))}"`,
|
`"${ORIGIN}${endpoint.path
|
||||||
|
.replace('{key}', sampleKey(endpoint.path))
|
||||||
|
.replace('{lang}', 'fr')
|
||||||
|
.replace('{slug}', 'comment-nous-chiffrons-une-renovation')
|
||||||
|
.replace('{story_id}', '5')
|
||||||
|
.replace('{id}', String(endpoint.pathId))}"`,
|
||||||
)
|
)
|
||||||
expect(examples.response).toBe(endpoint.response)
|
expect(examples.response).toBe(endpoint.response)
|
||||||
expect(examples.response.trim()).not.toBe('')
|
expect(examples.response.trim()).not.toBe('')
|
||||||
@@ -346,6 +353,8 @@ describe('Catalogue completeness', () => {
|
|||||||
).toEqual([
|
).toEqual([
|
||||||
'GET /v1/article/',
|
'GET /v1/article/',
|
||||||
'GET /v1/article/{id}',
|
'GET /v1/article/{id}',
|
||||||
|
'GET /v1/public/site/{key}/lang/{lang}/article/{slug}',
|
||||||
|
'GET /v1/public/site/{key}/story/{story_id}',
|
||||||
'GET /v1/article/{id}/seo',
|
'GET /v1/article/{id}/seo',
|
||||||
'GET /v1/site/{key}/sitemap',
|
'GET /v1/site/{key}/sitemap',
|
||||||
])
|
])
|
||||||
@@ -386,6 +395,8 @@ describe('SEO output routes', () => {
|
|||||||
'modified_at',
|
'modified_at',
|
||||||
'fallbacks',
|
'fallbacks',
|
||||||
'json_ld',
|
'json_ld',
|
||||||
|
'lang',
|
||||||
|
'alternates',
|
||||||
].sort(),
|
].sort(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -403,7 +414,7 @@ describe('SEO output routes', () => {
|
|||||||
const sitemap = find('GET /v1/site/{key}/sitemap')
|
const sitemap = find('GET /v1/site/{key}/sitemap')
|
||||||
expect(sitemap.query.map((field) => field.name)).toEqual(['page', 'size'])
|
expect(sitemap.query.map((field) => field.name)).toEqual(['page', 'size'])
|
||||||
for (const item of parsed(sitemap)) {
|
for (const item of parsed(sitemap)) {
|
||||||
expect(keysOf(item)).toEqual(['modified_at', 'noindex', 'url'])
|
expect(keysOf(item)).toEqual(['lang', 'modified_at', 'noindex', 'url'])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -578,7 +589,15 @@ describe('Response samples use the real domain JSON keys', () => {
|
|||||||
|
|
||||||
it('keeps id, title and published on usage items and adds type and fields', () => {
|
it('keeps id, title and published on usage items and adds type and fields', () => {
|
||||||
const usage = parsed(find('GET /v1/media/{id}/usage'))
|
const usage = parsed(find('GET /v1/media/{id}/usage'))
|
||||||
expect(keysOf(usage[0])).toEqual(['fields', 'id', 'published', 'title', 'type'])
|
expect(keysOf(usage[0])).toEqual([
|
||||||
|
'fields',
|
||||||
|
'id',
|
||||||
|
'lang',
|
||||||
|
'published',
|
||||||
|
'story_id',
|
||||||
|
'title',
|
||||||
|
'type',
|
||||||
|
])
|
||||||
expect(usage.map((item: { type: string }) => item.type)).toEqual([
|
expect(usage.map((item: { type: string }) => item.type)).toEqual([
|
||||||
'article',
|
'article',
|
||||||
'author',
|
'author',
|
||||||
|
|||||||
@@ -169,6 +169,22 @@ const ARTICLE_LIST_QUERY: ApiField[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const STORY_QUERY: ApiField[] = [
|
||||||
|
{
|
||||||
|
name: 'lang',
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
rule: 'A language tag such as fr or pt-BR. Default: the site default language.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fallback',
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
rule: 'Only the value default: serve the published default-language version when the requested one is not published.',
|
||||||
|
},
|
||||||
|
FORMAT_QUERY,
|
||||||
|
]
|
||||||
|
|
||||||
const SITEMAP_QUERY: ApiField[] = [
|
const SITEMAP_QUERY: ApiField[] = [
|
||||||
{
|
{
|
||||||
name: 'page',
|
name: 'page',
|
||||||
@@ -340,7 +356,8 @@ const resolvedSeo = () => ({
|
|||||||
image_alt: 'A tiled bathroom wall',
|
image_alt: 'A tiled bathroom wall',
|
||||||
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||||
site_name: 'Renew',
|
site_name: 'Renew',
|
||||||
locale: 'en',
|
locale: 'en_GB',
|
||||||
|
locale_alternates: ['fr_FR'],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -363,7 +380,13 @@ const resolvedSeo = () => ({
|
|||||||
],
|
],
|
||||||
published_at: '2026-03-12T09:14:00Z',
|
published_at: '2026-03-12T09:14:00Z',
|
||||||
modified_at: '2026-09-10T07:00:00Z',
|
modified_at: '2026-09-10T07:00:00Z',
|
||||||
fallbacks: { title: 'title', description: 'excerpt', image: 'cover', canonical: 'site' },
|
fallbacks: {
|
||||||
|
title: 'title',
|
||||||
|
description: 'excerpt',
|
||||||
|
image: 'cover',
|
||||||
|
canonical: 'pattern',
|
||||||
|
site_text_skipped: false,
|
||||||
|
},
|
||||||
json_ld: {
|
json_ld: {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Article',
|
'@type': 'Article',
|
||||||
@@ -385,6 +408,16 @@ const resolvedSeo = () => ({
|
|||||||
'@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
'@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
lang: 'en',
|
||||||
|
alternates: [
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
lang: 'fr',
|
||||||
|
story_id: 5,
|
||||||
|
slug: 'comment-nous-chiffrons-une-renovation',
|
||||||
|
url: 'https://blog.renew.example/fr/comment-nous-chiffrons-une-renovation',
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const seoCheck = () => ({
|
const seoCheck = () => ({
|
||||||
@@ -396,16 +429,53 @@ const seoCheck = () => ({
|
|||||||
slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' },
|
slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const PUBLIC_SAMPLE = {
|
||||||
|
lang: 'fr',
|
||||||
|
slug: 'comment-nous-chiffrons-une-renovation',
|
||||||
|
story: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
const alternates = () => [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
lang: 'en',
|
||||||
|
story_id: 5,
|
||||||
|
slug: 'how-we-price-a-bathroom-renovation',
|
||||||
|
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
lang: 'de',
|
||||||
|
story_id: 5,
|
||||||
|
slug: 'so-kalkulieren-wir-eine-badsanierung',
|
||||||
|
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const frenchVersion = (extra: Record<string, unknown> = {}) => ({
|
||||||
|
...article({
|
||||||
|
id: 12,
|
||||||
|
title: 'Comment nous chiffrons une rénovation',
|
||||||
|
slug: PUBLIC_SAMPLE.slug,
|
||||||
|
lang: 'fr',
|
||||||
|
story_id: 5,
|
||||||
|
}),
|
||||||
|
alternates: alternates(),
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
const sitemapPage = () => [
|
const sitemapPage = () => [
|
||||||
{
|
{
|
||||||
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||||
modified_at: '2026-09-10T07:00:00Z',
|
modified_at: '2026-09-10T07:00:00Z',
|
||||||
noindex: false,
|
noindex: false,
|
||||||
|
lang: 'en',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
|
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
|
||||||
modified_at: '2026-09-09T07:00:00Z',
|
modified_at: '2026-09-09T07:00:00Z',
|
||||||
noindex: true,
|
noindex: true,
|
||||||
|
lang: 'en',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -758,7 +828,8 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
{
|
{
|
||||||
key: 'articles',
|
key: 'articles',
|
||||||
title: 'Articles',
|
title: 'Articles',
|
||||||
description: "Read, write and publish articles. Titles can't be changed after creation.",
|
description:
|
||||||
|
'Read, write and publish articles. A title is edited like any other field and never changes the slug.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint(
|
endpoint(
|
||||||
'art-list',
|
'art-list',
|
||||||
@@ -780,6 +851,38 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
json(article()),
|
json(article()),
|
||||||
{ query: [FORMAT_QUERY] },
|
{ query: [FORMAT_QUERY] },
|
||||||
),
|
),
|
||||||
|
endpoint(
|
||||||
|
'art-public-lookup',
|
||||||
|
'GET',
|
||||||
|
'/v1/public/site/{key}/lang/{lang}/article/{slug}',
|
||||||
|
'Exact lookup by site, language and slug. Returns the published version and its alternates, the other published versions of the article in enabled languages. It never falls back to another language, ignores any cookie or key, and never returns a draft.',
|
||||||
|
'Public',
|
||||||
|
[
|
||||||
|
e(400, 'invalid_language'),
|
||||||
|
e(400, FORMAT_INVALID),
|
||||||
|
e(404, 'article_not_found'),
|
||||||
|
e(404, 'site_not_found'),
|
||||||
|
],
|
||||||
|
json(frenchVersion()),
|
||||||
|
{ query: [FORMAT_QUERY] },
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
'art-public-story',
|
||||||
|
'GET',
|
||||||
|
'/v1/public/site/{key}/story/{story_id}',
|
||||||
|
'Resolve an article by its story_id in a language, with an explicit fallback to the default language only. The answer adds requested_lang and fallback to the version and its alternates. Without lang the site default language is requested.',
|
||||||
|
'Public',
|
||||||
|
[
|
||||||
|
e(400, 'invalid_language'),
|
||||||
|
e(400, BODY_MISMATCH),
|
||||||
|
e(400, FORMAT_INVALID),
|
||||||
|
e(400, ID_FORMAT),
|
||||||
|
e(404, 'language_not_available'),
|
||||||
|
e(404, 'site_not_found'),
|
||||||
|
],
|
||||||
|
json(frenchVersion({ requested_lang: 'fr', fallback: false })),
|
||||||
|
{ query: STORY_QUERY },
|
||||||
|
),
|
||||||
endpoint(
|
endpoint(
|
||||||
'art-create',
|
'art-create',
|
||||||
'POST',
|
'POST',
|
||||||
@@ -1016,7 +1119,7 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
'site-sitemap',
|
'site-sitemap',
|
||||||
'GET',
|
'GET',
|
||||||
'/v1/site/{key}/sitemap',
|
'/v1/site/{key}/sitemap',
|
||||||
'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.',
|
'List the published URLs of a site, one per version in an enabled language, by id ascending, as an array of { url, modified_at, noindex, lang }. The total is in the X-Total-Count header.',
|
||||||
'Public',
|
'Public',
|
||||||
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
|
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
|
||||||
json(sitemapPage()),
|
json(sitemapPage()),
|
||||||
@@ -1407,7 +1510,7 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
'med-use',
|
'med-use',
|
||||||
'GET',
|
'GET',
|
||||||
'/v1/media/{id}/usage',
|
'/v1/media/{id}/usage',
|
||||||
'List the articles, authors and sites that use an image, with the places where each one uses it.',
|
'List the articles, authors and sites that use an image, with the places where each one uses it. There is one article item per version, with its story_id and lang.',
|
||||||
'Verified user',
|
'Verified user',
|
||||||
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
|
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
|
||||||
json([
|
json([
|
||||||
@@ -1417,6 +1520,8 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
title: ARTICLE_TITLE,
|
title: ARTICLE_TITLE,
|
||||||
published: true,
|
published: true,
|
||||||
fields: ['body', 'cover'],
|
fields: ['body', 'cover'],
|
||||||
|
story_id: 5,
|
||||||
|
lang: 'en',
|
||||||
},
|
},
|
||||||
{ type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] },
|
{ type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] },
|
||||||
{ type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] },
|
{ type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] },
|
||||||
@@ -1983,7 +2088,11 @@ function sampleKey(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
|
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
|
||||||
const keyed = item.path.replace('{key}', sampleKey(item.path))
|
const keyed = item.path
|
||||||
|
.replace('{key}', sampleKey(item.path))
|
||||||
|
.replace('{lang}', PUBLIC_SAMPLE.lang)
|
||||||
|
.replace('{slug}', PUBLIC_SAMPLE.slug)
|
||||||
|
.replace('{story_id}', String(PUBLIC_SAMPLE.story))
|
||||||
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
|
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { articleListMessages } from './articleListMessages'
|
import { articleListMessages } from './articleListMessages'
|
||||||
import { editorMessages } from './editorMessages'
|
import { editorMessages } from './editorMessages'
|
||||||
import { localizationMessages } from './localizationMessages'
|
import { localizationMessages } from './localizationMessages'
|
||||||
|
import { surfaceMessages } from './surfaceMessages'
|
||||||
import { translationFlowMessages } from './translationFlowMessages'
|
import { translationFlowMessages } from './translationFlowMessages'
|
||||||
|
|
||||||
export const messages = {
|
export const messages = {
|
||||||
...localizationMessages,
|
...localizationMessages,
|
||||||
|
...surfaceMessages,
|
||||||
...articleListMessages,
|
...articleListMessages,
|
||||||
...editorMessages,
|
...editorMessages,
|
||||||
...translationFlowMessages,
|
...translationFlowMessages,
|
||||||
@@ -1614,8 +1616,6 @@ export const messages = {
|
|||||||
'newest, oldest, name, largest or total_size. Default newest.':
|
'newest, oldest, name, largest or total_size. Default newest.':
|
||||||
'newest, oldest, name, largest ou total_size. Par défaut newest.',
|
'newest, oldest, name, largest ou total_size. Par défaut newest.',
|
||||||
'with or without. Default both.': 'with ou without. Par défaut les deux.',
|
'with or without. Default both.': 'with ou without. Par défaut les deux.',
|
||||||
"Read, write and publish articles. Titles can't be changed after creation.":
|
|
||||||
'Lire, écrire et publier des articles. Les titres ne peuvent pas être modifiés après la création.',
|
|
||||||
"3 to 200 characters. Can't be changed later.":
|
"3 to 200 characters. Can't be changed later.":
|
||||||
'De 3 à 200 caractères. Ne peut pas être modifié ensuite.',
|
'De 3 à 200 caractères. Ne peut pas être modifié ensuite.',
|
||||||
'Up to 10 000 characters.': '10 000 caractères maximum.',
|
'Up to 10 000 characters.': '10 000 caractères maximum.',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const SAME_IN_FRENCH = new Set([
|
|||||||
'{0} suggestions',
|
'{0} suggestions',
|
||||||
'Suggestions',
|
'Suggestions',
|
||||||
'Mobile',
|
'Mobile',
|
||||||
|
'Description',
|
||||||
])
|
])
|
||||||
|
|
||||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
export const surfaceMessages = {
|
||||||
|
'Created {0} version of "{1}"': 'Version en {0} créée : « {1} »',
|
||||||
|
'Failed to create {0} version of "{1}"': 'Échec de la création de la version en {0} : « {1} »',
|
||||||
|
'Edited {0} version of "{1}"': 'Version en {0} modifiée : « {1} »',
|
||||||
|
'Failed to edit {0} version of "{1}"':
|
||||||
|
'Échec de la modification de la version en {0} : « {1} »',
|
||||||
|
'Published {0} version of "{1}"': 'Version en {0} publiée : « {1} »',
|
||||||
|
'Failed to publish {0} version of "{1}"':
|
||||||
|
'Échec de la publication de la version en {0} : « {1} »',
|
||||||
|
'Unpublished {0} version of "{1}"': 'Version en {0} dépubliée : « {1} »',
|
||||||
|
'Failed to unpublish {0} version of "{1}"':
|
||||||
|
'Échec de la dépublication de la version en {0} : « {1} »',
|
||||||
|
'Deleted {0} version of "{1}"': 'Version en {0} supprimée : « {1} »',
|
||||||
|
'Failed to delete {0} version of "{1}"':
|
||||||
|
'Échec de la suppression de la version en {0} : « {1} »',
|
||||||
|
'API URL copied': 'URL de l’API copiée',
|
||||||
|
'Add a public URL pattern for {0} to show page and canonical addresses. API links by article ID still work.':
|
||||||
|
'Ajoutez un modèle d’URL publique pour {0} afin d’afficher les adresses de page et canoniques. Les liens d’API par identifiant d’article fonctionnent toujours.',
|
||||||
|
'Address (slug)': 'Adresse (slug)',
|
||||||
|
'Address changed': 'Adresse modifiée',
|
||||||
|
'Alternate languages': 'Langues alternatives',
|
||||||
|
'Applies only to the {0} version. Page language: {1}.':
|
||||||
|
'S’applique uniquement à la version en {0}. Langue de la page : {1}.',
|
||||||
|
'Built from the Public URL pattern {0}. Each language points to itself unless you set a canonical override.':
|
||||||
|
'Construite à partir du modèle d’URL publique {0}. Chaque langue pointe vers elle-même, sauf si vous définissez une canonique manuelle.',
|
||||||
|
Canonical: 'Canonique',
|
||||||
|
'Change address': 'Changer l’adresse',
|
||||||
|
'Drafts and the disabled languages {0} are left out.':
|
||||||
|
'Les brouillons et les langues désactivées ({0}) sont exclus.',
|
||||||
|
'Drafts and the disabled {0} language are left out.':
|
||||||
|
'Les brouillons et la langue désactivée ({0}) sont exclus.',
|
||||||
|
'Drafts are left out.': 'Les brouillons sont exclus.',
|
||||||
|
'Each language version has its own address. The URL carries the language.':
|
||||||
|
'Chaque version linguistique a sa propre adresse. L’URL porte la langue.',
|
||||||
|
'Edit slug for the {0} version': 'Modifier le slug de la version en {0}',
|
||||||
|
'Editing the title never changes the slug.': 'Modifier le titre ne change jamais le slug.',
|
||||||
|
'Editing the title never changes the slug. Only Edit slug does.':
|
||||||
|
'Modifier le titre ne change jamais le slug. Seul « Modifier le slug » le fait.',
|
||||||
|
'Fallback: {0} body': 'Repli : corps en {0}',
|
||||||
|
'Fallback: {0} excerpt': 'Repli : extrait en {0}',
|
||||||
|
'Fallback: {0} site description': 'Repli : description du site en {0}',
|
||||||
|
'Fallback: {0} title': 'Repli : titre en {0}',
|
||||||
|
'How it can appear in search': 'Aperçu dans les résultats de recherche',
|
||||||
|
'Image alternative text': 'Texte alternatif de l’image',
|
||||||
|
'Links to the old address will stop working. RenewCMS does not create redirects.':
|
||||||
|
'Les liens vers l’ancienne adresse cesseront de fonctionner. RenewCMS ne crée pas de redirections.',
|
||||||
|
'No fallback': 'Aucun repli',
|
||||||
|
'No fallback. Empty. {0} site default not used. The tag is left out.':
|
||||||
|
'Aucun repli. Vide. Le texte du site par défaut en {0} n’est pas utilisé. La balise est omise.',
|
||||||
|
'Open Public URL settings': 'Ouvrir les réglages d’URL publique',
|
||||||
|
'Page title': 'Titre de la page',
|
||||||
|
'Published versions in enabled languages only.':
|
||||||
|
'Versions publiées dans les langues activées uniquement.',
|
||||||
|
'Search and sharing · {0}': 'Recherche et partage · {0}',
|
||||||
|
'Set by an owner for {0} in {1} languages. A region is never guessed from the language.':
|
||||||
|
'Défini par un propriétaire pour la langue {0} dans les langues de {1}. Une région n’est jamais déduite de la langue.',
|
||||||
|
'Set by hand as a canonical override on this version.':
|
||||||
|
'Définie à la main comme canonique de cette version.',
|
||||||
|
'Shows the localized address and canonical. Description: {0}.':
|
||||||
|
'Affiche l’adresse localisée et la canonique. Description : {0}.',
|
||||||
|
'Social locale not set': 'Locale sociale non définie',
|
||||||
|
'Tag left out': 'Balise omise',
|
||||||
|
'Taken from the region of the language code or of the site locale. An owner can set another in {0} languages.':
|
||||||
|
'Issue de la région du code de langue ou de la locale du site. Un propriétaire peut en définir une autre dans les langues de {0}.',
|
||||||
|
'The address changes for {0} only.': 'L’adresse ne change que pour la version en {0}.',
|
||||||
|
'The address of the {0} version is now {1}.':
|
||||||
|
'L’adresse de la version en {0} est maintenant {1}.',
|
||||||
|
'This version is published': 'Cette version est publiée',
|
||||||
|
'Unique within {0} and {1}. Editing the title never changes the address.':
|
||||||
|
'Unique dans {0} et en {1}. Modifier le titre ne change jamais l’adresse.',
|
||||||
|
'Uses site text': 'Utilise le texte du site',
|
||||||
|
'Written per language.': 'Rédigé pour chaque langue.',
|
||||||
|
'{0} has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in {1} languages.':
|
||||||
|
'{0} n’a pas de région dans son code et aucune locale sociale n’est définie, donc og:locale et og:locale:alternate sont omis. Un propriétaire peut en définir une dans les langues de {1}.',
|
||||||
|
Languages: 'Langues',
|
||||||
|
'Read one published language of an article, or resolve an article in a language. Both requests are public.':
|
||||||
|
'Lisez une langue publiée d’un article, ou résolvez un article dans une langue. Les deux requêtes sont publiques.',
|
||||||
|
'1. Exact lookup: site + language + slug': '1. Recherche exacte : site + langue + slug',
|
||||||
|
'Matches the published version with that slug in that language, or returns article_not_found. It never falls back, because a slug belongs to one language.':
|
||||||
|
'Trouve la version publiée avec ce slug dans cette langue, ou renvoie article_not_found. Aucun repli, car un slug appartient à une seule langue.',
|
||||||
|
'2. Resolve by article group, with fallback': '2. Résolution par groupe d’articles, avec repli',
|
||||||
|
'Use story_id from any response or from alternates[]. French published: lang "fr". French missing or unpublished with fallback=default: the published default, with "lang": "en", "requested_lang": "fr", "fallback": true. Without fallback=default, a missing French is language_not_available. No other language is tried, and drafts are never returned.':
|
||||||
|
'Utilisez story_id d’une réponse ou de alternates[]. Français publié : lang "fr". Français absent ou non publié avec fallback=default : la version par défaut publiée, avec "lang": "en", "requested_lang": "fr", "fallback": true. Sans fallback=default, un français absent donne language_not_available. Aucune autre langue n’est essayée et les brouillons ne sont jamais renvoyés.',
|
||||||
|
'3. Errors': '3. Erreurs',
|
||||||
|
'article_not_found: no published version has that slug in that language. language_not_available: the requested language and the allowed fallback are both unavailable. site_not_found: no site has that key. invalid_language: the language is not a language tag.':
|
||||||
|
'article_not_found : aucune version publiée n’a ce slug dans cette langue. language_not_available : la langue demandée et le repli autorisé sont indisponibles. site_not_found : aucun site n’a cette clé. invalid_language : la langue n’est pas une balise de langue.',
|
||||||
|
'Existing, unchanged': 'Existant, inchangé',
|
||||||
|
'Each article ID is one language version. Article-ID links keep working.':
|
||||||
|
'Chaque identifiant d’article est une version linguistique. Les liens par identifiant d’article continuent de fonctionner.',
|
||||||
|
'Read, write and publish articles. A title is edited like any other field and never changes the slug.':
|
||||||
|
'Lire, écrire et publier des articles. Un titre se modifie comme n’importe quel champ et ne change jamais le slug.',
|
||||||
|
'Exact lookup by site, language and slug. Returns the published version and its alternates, the other published versions of the article in enabled languages. It never falls back to another language, ignores any cookie or key, and never returns a draft.':
|
||||||
|
'Recherche exacte par site, langue et slug. Renvoie la version publiée et ses alternatives, les autres versions publiées de l’article dans les langues activées. Aucun repli vers une autre langue, tout cookie ou clé est ignoré et aucun brouillon n’est renvoyé.',
|
||||||
|
'A language tag such as fr or pt-BR. Default: the site default language.':
|
||||||
|
'Une balise de langue comme fr ou pt-BR. Par défaut : la langue par défaut du site.',
|
||||||
|
'Only the value default: serve the published default-language version when the requested one is not published.':
|
||||||
|
'Uniquement la valeur default : renvoie la version publiée dans la langue par défaut quand celle demandée n’est pas publiée.',
|
||||||
|
'List the published URLs of a site, one per version in an enabled language, by id ascending, as an array of { url, modified_at, noindex, lang }. The total is in the X-Total-Count header.':
|
||||||
|
'Liste les URL publiées d’un site, une par version dans une langue activée, par identifiant croissant, sous forme de tableau de { url, modified_at, noindex, lang }. Le total est dans l’en-tête X-Total-Count.',
|
||||||
|
'List the articles, authors and sites that use an image, with the places where each one uses it. There is one article item per version, with its story_id and lang.':
|
||||||
|
'Liste les articles, auteurs et sites qui utilisent une image, avec les endroits où chacun l’utilise. Il y a un élément d’article par version, avec son story_id et sa lang.',
|
||||||
|
'Resolve an article by its story_id in a language, with an explicit fallback to the default language only. The answer adds requested_lang and fallback to the version and its alternates. Without lang the site default language is requested.':
|
||||||
|
'Résout un article par son story_id dans une langue, avec un repli explicite vers la langue par défaut uniquement. La réponse ajoute requested_lang et fallback à la version et à ses alternatives. Sans lang, la langue par défaut du site est demandée.',
|
||||||
|
} as const
|
||||||
@@ -625,3 +625,48 @@ describe('Roles sync: the six role events in EN and FR', () => {
|
|||||||
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
|
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Sentences name the language of an article version', () => {
|
||||||
|
const actions = ['create', 'edit', 'publish', 'unpublish', 'delete']
|
||||||
|
const english: Record<string, string> = {
|
||||||
|
create: 'Created French version of "Spring release"',
|
||||||
|
edit: 'Edited French version of "Spring release"',
|
||||||
|
publish: 'Published French version of "Spring release"',
|
||||||
|
unpublish: 'Unpublished French version of "Spring release"',
|
||||||
|
delete: 'Deleted French version of "Spring release"',
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(actions)('names French in article.%s', (action) => {
|
||||||
|
const text = composeSentence(item({ action: `article.${action}`, params: { lang: 'fr' } }))
|
||||||
|
.map((segment) => segment.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
expect(text).toBe(english[action])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads in French from the same params', () => {
|
||||||
|
setLocale('fr')
|
||||||
|
|
||||||
|
const text = composeSentence(item({ params: { lang: 'fr' } }))
|
||||||
|
.map((segment) => segment.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
expect(text).toBe('Version en français publiée : « Spring release »')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the failure of a version', () => {
|
||||||
|
const text = composeSentence(item({ outcome: 'failed', params: { lang: 'de' } }))
|
||||||
|
.map((segment) => segment.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
expect(text).toBe('Failed to publish German version of "Spring release"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the sentence of an event without a language', () => {
|
||||||
|
const text = composeSentence(item({ params: {} }))
|
||||||
|
.map((segment) => segment.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
expect(text).toBe('Published the article Spring release')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -65,9 +65,9 @@ describe('index', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('holds 89 endpoints: 43 documented, 40 session only and 6 public auth', () => {
|
it('holds 91 endpoints: 45 documented, 40 session only and 6 public auth', () => {
|
||||||
expect(index.endpoints).toHaveLength(89)
|
expect(index.endpoints).toHaveLength(91)
|
||||||
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(43)
|
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(45)
|
||||||
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(40)
|
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(40)
|
||||||
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
|
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
|
||||||
})
|
})
|
||||||
@@ -202,7 +202,7 @@ describe('matching', () => {
|
|||||||
describe('custom content types', () => {
|
describe('custom content types', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['media', 16, 1],
|
['media', 16, 1],
|
||||||
['404', 24, 1],
|
['404', 26, 1],
|
||||||
['login', 1, 1],
|
['login', 1, 1],
|
||||||
['entry', 7, 1],
|
['entry', 7, 1],
|
||||||
['capacity', 2, 0],
|
['capacity', 2, 0],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { sizeLabel } from '@/lib/activityRetention'
|
|||||||
import { formatSize } from '@/lib/mediaFormat'
|
import { formatSize } from '@/lib/mediaFormat'
|
||||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||||
import { causeText } from '@/lib/variants/variantCauses'
|
import { causeText } from '@/lib/variants/variantCauses'
|
||||||
|
import { nameInSentence } from '@/lib/storyList'
|
||||||
|
|
||||||
export interface SentenceSegment {
|
export interface SentenceSegment {
|
||||||
text: string
|
text: string
|
||||||
@@ -22,6 +23,34 @@ const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
|
|||||||
const FRENCH_ELIDING_ACTIONS = ['user.change_role', 'user.remove']
|
const FRENCH_ELIDING_ACTIONS = ['user.change_role', 'user.remove']
|
||||||
const FRENCH_VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
const FRENCH_VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
||||||
|
|
||||||
|
const LANGUAGE_CATALOGUE: Record<string, Template> = {
|
||||||
|
'article.create': {
|
||||||
|
success: 'Created {0} version of "{1}"',
|
||||||
|
failed: 'Failed to create {0} version of "{1}"',
|
||||||
|
},
|
||||||
|
'article.edit': {
|
||||||
|
success: 'Edited {0} version of "{1}"',
|
||||||
|
failed: 'Failed to edit {0} version of "{1}"',
|
||||||
|
},
|
||||||
|
'article.publish': {
|
||||||
|
success: 'Published {0} version of "{1}"',
|
||||||
|
failed: 'Failed to publish {0} version of "{1}"',
|
||||||
|
},
|
||||||
|
'article.unpublish': {
|
||||||
|
success: 'Unpublished {0} version of "{1}"',
|
||||||
|
failed: 'Failed to unpublish {0} version of "{1}"',
|
||||||
|
},
|
||||||
|
'article.delete': {
|
||||||
|
success: 'Deleted {0} version of "{1}"',
|
||||||
|
failed: 'Failed to delete {0} version of "{1}"',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionLanguage(item: ActivityItem) {
|
||||||
|
const tag = item.params.lang
|
||||||
|
return typeof tag === 'string' && tag !== '' && item.action in LANGUAGE_CATALOGUE ? tag : null
|
||||||
|
}
|
||||||
|
|
||||||
const CATALOGUE: Record<string, Template> = {
|
const CATALOGUE: Record<string, Template> = {
|
||||||
'article.create': {
|
'article.create': {
|
||||||
success: 'Created the article {0}',
|
success: 'Created the article {0}',
|
||||||
@@ -317,6 +346,10 @@ function reassignParts(item: ActivityItem): SentenceParts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveTemplate(item: ActivityItem): MessageKey | null {
|
function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||||
|
if (versionLanguage(item) !== null) {
|
||||||
|
const named = LANGUAGE_CATALOGUE[item.action]!
|
||||||
|
return item.outcome === 'failed' ? (named.failed ?? named.success) : named.success
|
||||||
|
}
|
||||||
const template = CATALOGUE[item.action]
|
const template = CATALOGUE[item.action]
|
||||||
if (!template) return null
|
if (!template) return null
|
||||||
if (item.action === 'role.reassign') return reassignParts(item).template
|
if (item.action === 'role.reassign') return reassignParts(item).template
|
||||||
@@ -339,6 +372,10 @@ function resolveTemplate(item: ActivityItem): MessageKey | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function slotValues(item: ActivityItem, template: MessageKey) {
|
function slotValues(item: ActivityItem, template: MessageKey) {
|
||||||
|
const tag = versionLanguage(item)
|
||||||
|
if (tag !== null) {
|
||||||
|
return [nameInSentence(tag), TARGET_SLOT]
|
||||||
|
}
|
||||||
if (item.action === 'role.reassign') {
|
if (item.action === 'role.reassign') {
|
||||||
return reassignParts(item).values
|
return reassignParts(item).values
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { locale, t } from '@/i18n'
|
||||||
|
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||||
|
import { languageName } from '@/lib/siteLanguages'
|
||||||
|
import { nameInSentence } from '@/lib/storyList'
|
||||||
|
|
||||||
|
export interface LocalizedSeoContext {
|
||||||
|
lang: string
|
||||||
|
siteName: string
|
||||||
|
siteKey: string
|
||||||
|
pattern: string | null
|
||||||
|
defaultLang: string
|
||||||
|
disabledLangs: string[]
|
||||||
|
published: boolean
|
||||||
|
socialState: string
|
||||||
|
resolved: ResolvedSeo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageLanguage(context: LocalizedSeoContext) {
|
||||||
|
return languageName(context.lang, locale.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breadcrumbOf(url: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||||
|
return [parsed.host, ...segments].join(' › ')
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalNote(context: LocalizedSeoContext) {
|
||||||
|
if (context.resolved?.fallbacks.canonical === 'canonical_url') {
|
||||||
|
return t('Set by hand as a canonical override on this version.')
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
'Built from the Public URL pattern {0}. Each language points to itself unless you set a canonical override.',
|
||||||
|
[context.pattern ?? ''],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disabledNote(context: LocalizedSeoContext) {
|
||||||
|
if (context.disabledLangs.length === 0) {
|
||||||
|
return t('Drafts are left out.')
|
||||||
|
}
|
||||||
|
const names = context.disabledLangs.map((tag) => nameInSentence(tag))
|
||||||
|
const list = names.join(', ')
|
||||||
|
return names.length === 1
|
||||||
|
? t('Drafts and the disabled {0} language are left out.', [list])
|
||||||
|
: t('Drafts and the disabled languages {0} are left out.', [list])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function socialLocaleLine(context: LocalizedSeoContext) {
|
||||||
|
const value = context.resolved?.og.locale ?? ''
|
||||||
|
if (!value) {
|
||||||
|
return t('Social locale not set')
|
||||||
|
}
|
||||||
|
return context.socialState === 'derived' ? t('Derived: {0}', [value]) : t('Set: {0}', [value])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function socialLocaleNote(context: LocalizedSeoContext) {
|
||||||
|
const name = pageLanguage(context)
|
||||||
|
if (!context.resolved?.og.locale) {
|
||||||
|
return t(
|
||||||
|
'{0} has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in {1} languages.',
|
||||||
|
[name, context.siteName],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (context.socialState === 'derived') {
|
||||||
|
return t(
|
||||||
|
'Taken from the region of the language code or of the site locale. An owner can set another in {0} languages.',
|
||||||
|
[context.siteName],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
'Set by an owner for {0} in {1} languages. A region is never guessed from the language.',
|
||||||
|
[name, context.siteName],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleHelp(context: LocalizedSeoContext) {
|
||||||
|
const source = context.resolved?.fallbacks.title
|
||||||
|
return source === 'title' ? t('Fallback: {0} title', [nameInSentence(context.lang)]) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descriptionHelp(context: LocalizedSeoContext) {
|
||||||
|
const fallbacks = context.resolved?.fallbacks
|
||||||
|
if (!fallbacks) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const name = nameInSentence(context.lang)
|
||||||
|
switch (fallbacks.description) {
|
||||||
|
case 'excerpt':
|
||||||
|
return t('Fallback: {0} excerpt', [name])
|
||||||
|
case 'body':
|
||||||
|
return t('Fallback: {0} body', [name])
|
||||||
|
case 'site':
|
||||||
|
return t('Fallback: {0} site description', [name])
|
||||||
|
case 'none':
|
||||||
|
return fallbacks.site_text_skipped
|
||||||
|
? t('No fallback. Empty. {0} site default not used. The tag is left out.', [
|
||||||
|
nameInSentence(context.defaultLang),
|
||||||
|
])
|
||||||
|
: t('No fallback')
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descriptionBadge(context: LocalizedSeoContext) {
|
||||||
|
const fallbacks = context.resolved?.fallbacks
|
||||||
|
if (fallbacks?.description === 'site') {
|
||||||
|
return t('Uses site text')
|
||||||
|
}
|
||||||
|
return fallbacks?.site_text_skipped ? t('Tag left out') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function altHelp() {
|
||||||
|
return t('Written per language.')
|
||||||
|
}
|
||||||
@@ -2,15 +2,23 @@ import { t } from '@/i18n'
|
|||||||
import type { MediaUsageItem, UsageState } from '@/composables/useMedia'
|
import type { MediaUsageItem, UsageState } from '@/composables/useMedia'
|
||||||
import { describeCell, plainCellText } from '@/lib/entryCells'
|
import { describeCell, plainCellText } from '@/lib/entryCells'
|
||||||
import { pluralArticles } from '@/lib/mediaFormat'
|
import { pluralArticles } from '@/lib/mediaFormat'
|
||||||
|
import { nameInSentence } from '@/lib/storyList'
|
||||||
|
|
||||||
const ENTRY_ROWS_SHOWN = 3
|
const ENTRY_ROWS_SHOWN = 3
|
||||||
const ARTICLE_TYPE = 'article'
|
const ARTICLE_TYPE = 'article'
|
||||||
const ENTRY_TYPE = 'entry'
|
const ENTRY_TYPE = 'entry'
|
||||||
|
|
||||||
|
export interface UsageVersion {
|
||||||
|
id: number
|
||||||
|
lang: string
|
||||||
|
published: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageArticle {
|
export interface UsageArticle {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
published: boolean
|
published: boolean
|
||||||
|
versions: UsageVersion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageEntry {
|
export interface UsageEntry {
|
||||||
@@ -28,6 +36,7 @@ export interface UsageOther {
|
|||||||
|
|
||||||
export interface UsageSplit {
|
export interface UsageSplit {
|
||||||
articles: UsageArticle[]
|
articles: UsageArticle[]
|
||||||
|
showLanguages: boolean
|
||||||
others: UsageOther[]
|
others: UsageOther[]
|
||||||
entries: UsageEntry[]
|
entries: UsageEntry[]
|
||||||
entryCount: number
|
entryCount: number
|
||||||
@@ -72,14 +81,41 @@ function toEntry(item: MediaUsageItem): UsageEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitUsage(items: MediaUsageItem[]): UsageSplit {
|
function groupArticles(items: MediaUsageItem[]): UsageArticle[] {
|
||||||
const articles = items
|
const groups = new Map<string, UsageArticle>()
|
||||||
.filter((item) => item.type === undefined || item.type === ARTICLE_TYPE)
|
for (const item of items) {
|
||||||
.map((item) => ({
|
const key = item.lang && item.story_id ? `story-${item.story_id}` : `article-${item.id}`
|
||||||
|
const version = {
|
||||||
|
id: item.id,
|
||||||
|
lang: item.lang ?? '',
|
||||||
|
published: item.published === true,
|
||||||
|
}
|
||||||
|
const existing = groups.get(key)
|
||||||
|
if (existing) {
|
||||||
|
existing.versions.push(version)
|
||||||
|
existing.published = existing.published || version.published
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups.set(key, {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title ?? '',
|
title: item.title ?? '',
|
||||||
published: item.published === true,
|
published: version.published,
|
||||||
}))
|
versions: [version],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...groups.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function showsLanguages(articles: UsageArticle[]) {
|
||||||
|
const tags = new Set(articles.flatMap((article) => article.versions.map((v) => v.lang)))
|
||||||
|
tags.delete('')
|
||||||
|
return tags.size > 1 || articles.some((article) => article.versions.length > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitUsage(items: MediaUsageItem[]): UsageSplit {
|
||||||
|
const articles = groupArticles(
|
||||||
|
items.filter((item) => item.type === undefined || item.type === ARTICLE_TYPE),
|
||||||
|
)
|
||||||
const entryItems = items.filter((item) => item.type === ENTRY_TYPE)
|
const entryItems = items.filter((item) => item.type === ENTRY_TYPE)
|
||||||
const others = items
|
const others = items
|
||||||
.filter((item) => item.type === 'author' || item.type === 'site')
|
.filter((item) => item.type === 'author' || item.type === 'site')
|
||||||
@@ -90,6 +126,7 @@ export function splitUsage(items: MediaUsageItem[]): UsageSplit {
|
|||||||
}))
|
}))
|
||||||
return {
|
return {
|
||||||
articles,
|
articles,
|
||||||
|
showLanguages: showsLanguages(articles),
|
||||||
others,
|
others,
|
||||||
entries: entryItems.slice(0, ENTRY_ROWS_SHOWN).map(toEntry),
|
entries: entryItems.slice(0, ENTRY_ROWS_SHOWN).map(toEntry),
|
||||||
entryCount: entryItems.length,
|
entryCount: entryItems.length,
|
||||||
@@ -155,3 +192,10 @@ export function deleteCopy(filename: string, usedIn: number, state: UsageState |
|
|||||||
split: known ? split : null,
|
split: known ? split : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usageLanguages(article: UsageArticle) {
|
||||||
|
return article.versions
|
||||||
|
.filter((version) => version.lang !== '')
|
||||||
|
.map((version) => nameInSentence(version.lang))
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const PUBLIC_LOOKUP_ROUTE = 'GET /v1/public/site/{key}/lang/{lang}/article/{slug}'
|
||||||
|
export const PUBLIC_STORY_ROUTE =
|
||||||
|
'GET /v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default'
|
||||||
+18
-1
@@ -54,6 +54,7 @@ export interface ResolvedSeo {
|
|||||||
url: string
|
url: string
|
||||||
site_name: string
|
site_name: string
|
||||||
locale: string
|
locale: string
|
||||||
|
locale_alternates?: string[]
|
||||||
}
|
}
|
||||||
twitter: {
|
twitter: {
|
||||||
card: string
|
card: string
|
||||||
@@ -66,6 +67,22 @@ export interface ResolvedSeo {
|
|||||||
authors: { name: string; url: string; same_as: string[] }[]
|
authors: { name: string; url: string; same_as: string[] }[]
|
||||||
published_at: string | null
|
published_at: string | null
|
||||||
modified_at: string
|
modified_at: string
|
||||||
fallbacks: { title: string; description: string; image: string; canonical: string }
|
fallbacks: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
image: string
|
||||||
|
canonical: string
|
||||||
|
site_text_skipped?: boolean
|
||||||
|
}
|
||||||
json_ld: Record<string, unknown>
|
json_ld: Record<string, unknown>
|
||||||
|
lang?: string
|
||||||
|
alternates?: SeoAlternate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeoAlternate {
|
||||||
|
id: number
|
||||||
|
lang: string
|
||||||
|
story_id: number
|
||||||
|
slug: string
|
||||||
|
url: string | null
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import ArticleDetails from '@/components/ArticleDetails.vue'
|
|||||||
import MetaSection from '@/components/MetaSection.vue'
|
import MetaSection from '@/components/MetaSection.vue'
|
||||||
import SearchSharingSection from '@/components/SearchSharingSection.vue'
|
import SearchSharingSection from '@/components/SearchSharingSection.vue'
|
||||||
import SeoPanel from '@/components/SeoPanel.vue'
|
import SeoPanel from '@/components/SeoPanel.vue'
|
||||||
|
import EditSlugDialog from '@/components/EditSlugDialog.vue'
|
||||||
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
|
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
|
||||||
import EditorActions from '@/components/EditorActions.vue'
|
import EditorActions from '@/components/EditorActions.vue'
|
||||||
import EditorHeader from '@/components/EditorHeader.vue'
|
import EditorHeader from '@/components/EditorHeader.vue'
|
||||||
@@ -43,6 +44,7 @@ import { useVersionSwitch } from '@/composables/useVersionSwitch'
|
|||||||
import { useEditorFocus } from '@/composables/useEditorFocus'
|
import { useEditorFocus } from '@/composables/useEditorFocus'
|
||||||
import { useEditorImages } from '@/composables/useEditorImages'
|
import { useEditorImages } from '@/composables/useEditorImages'
|
||||||
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
|
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
|
||||||
|
import { useEditorLocalizedSeo } from '@/composables/useEditorLocalizedSeo'
|
||||||
import { useSeoDismissals } from '@/composables/useSeoDismissals'
|
import { useSeoDismissals } from '@/composables/useSeoDismissals'
|
||||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||||
import { useSites, type Site } from '@/composables/useSites'
|
import { useSites, type Site } from '@/composables/useSites'
|
||||||
@@ -272,6 +274,16 @@ const capabilities = computed(() => article.value?.capabilities ?? [])
|
|||||||
const canEdit = computed(() => holds(capabilities.value, 'edit'))
|
const canEdit = computed(() => holds(capabilities.value, 'edit'))
|
||||||
const versions = useEditorVersions(article, sites, capabilities)
|
const versions = useEditorVersions(article, sites, capabilities)
|
||||||
|
|
||||||
|
const localizedSeo = useEditorLocalizedSeo({
|
||||||
|
articleId,
|
||||||
|
article,
|
||||||
|
site: computed(() => siteOfArticle.value),
|
||||||
|
versions,
|
||||||
|
draft,
|
||||||
|
panelOpen,
|
||||||
|
save: () => runSave(),
|
||||||
|
})
|
||||||
|
|
||||||
const offline = computed(() => draft.state.value === 'offline')
|
const offline = computed(() => draft.state.value === 'offline')
|
||||||
const unsavedChanges = computed(
|
const unsavedChanges = computed(
|
||||||
() => draft.businessState.value === 'dirty' || draft.businessState.value === 'error',
|
() => draft.businessState.value === 'dirty' || draft.businessState.value === 'error',
|
||||||
@@ -849,6 +861,8 @@ useUnsavedChangesGuard({
|
|||||||
:saving="panelSaving"
|
:saving="panelSaving"
|
||||||
:notice="panelNotice"
|
:notice="panelNotice"
|
||||||
:focus="panelFocus"
|
:focus="panelFocus"
|
||||||
|
:localized="localizedSeo.context.value"
|
||||||
|
@edit-slug="localizedSeo.openSlugDialog"
|
||||||
@update:model-value="updatePanelValues"
|
@update:model-value="updatePanelValues"
|
||||||
@check="onPanelCheck"
|
@check="onPanelCheck"
|
||||||
@submit="saveSeoPanel"
|
@submit="saveSeoPanel"
|
||||||
@@ -858,6 +872,16 @@ useUnsavedChangesGuard({
|
|||||||
@dismiss="dismissSuggestion"
|
@dismiss="dismissSuggestion"
|
||||||
@restore="restoreDismissed"
|
@restore="restoreDismissed"
|
||||||
/>
|
/>
|
||||||
|
<EditSlugDialog
|
||||||
|
v-if="localizedSeo.slugDialogOpen.value && localizedSeo.context.value"
|
||||||
|
:language="versions.displayName.value"
|
||||||
|
:slug="draft.savedSeo.value.slug"
|
||||||
|
:published="article.is_online"
|
||||||
|
:pending="localizedSeo.slugPending.value"
|
||||||
|
:error="localizedSeo.slugError.value"
|
||||||
|
@close="localizedSeo.closeSlugDialog"
|
||||||
|
@confirm="localizedSeo.confirmSlug"
|
||||||
|
/>
|
||||||
<ApiPreviewDialog
|
<ApiPreviewDialog
|
||||||
v-model:open="apiPreviewOpen"
|
v-model:open="apiPreviewOpen"
|
||||||
:article-id="articleId"
|
:article-id="articleId"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import NewArticleModal from '@/components/NewArticleModal.vue'
|
|||||||
import RecentActivityCard from '@/components/RecentActivityCard.vue'
|
import RecentActivityCard from '@/components/RecentActivityCard.vue'
|
||||||
import UpdateBanner from '@/components/UpdateBanner.vue'
|
import UpdateBanner from '@/components/UpdateBanner.vue'
|
||||||
import { useArticles, type Article, type ArticleCounts } from '@/composables/useArticles'
|
import { useArticles, type Article, type ArticleCounts } from '@/composables/useArticles'
|
||||||
|
import { useSites } from '@/composables/useSites'
|
||||||
|
import { useSiteLanguages } from '@/composables/useSiteLanguages'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { usePermissionsStore } from '@/stores/permissions'
|
import { usePermissionsStore } from '@/stores/permissions'
|
||||||
import { useUpdateStore } from '@/stores/update'
|
import { useUpdateStore } from '@/stores/update'
|
||||||
@@ -27,7 +29,11 @@ const toast = useLocalizedToast()
|
|||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const permissions = usePermissionsStore()
|
const permissions = usePermissionsStore()
|
||||||
const update = useUpdateStore()
|
const update = useUpdateStore()
|
||||||
const { listArticles } = useArticles()
|
const { listStories } = useArticles()
|
||||||
|
const { listAllSites } = useSites()
|
||||||
|
const { getLanguages } = useSiteLanguages()
|
||||||
|
|
||||||
|
const defaultLangBySite = ref<Record<number, string>>({})
|
||||||
|
|
||||||
const loadState = ref<LoadState>('loading')
|
const loadState = ref<LoadState>('loading')
|
||||||
const articles = ref<Article[]>([])
|
const articles = ref<Article[]>([])
|
||||||
@@ -93,6 +99,34 @@ function clearSkeletonTimer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDefaultLanguages(items: Article[]) {
|
||||||
|
const siteIds = [
|
||||||
|
...new Set(items.filter((item) => item.lang && item.site_id).map((item) => item.site_id!)),
|
||||||
|
]
|
||||||
|
if (siteIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sites = await listAllSites()
|
||||||
|
const known: Record<number, string> = {}
|
||||||
|
for (const site of sites.filter((candidate) => siteIds.includes(candidate.id))) {
|
||||||
|
const languages = await getLanguages(site.key)
|
||||||
|
known[site.id] = languages.default ?? ''
|
||||||
|
}
|
||||||
|
defaultLangBySite.value = known
|
||||||
|
} catch {
|
||||||
|
defaultLangBySite.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageCode(article: Article) {
|
||||||
|
const defaultLang = defaultLangBySite.value[article.site_id ?? 0]
|
||||||
|
if (!article.lang || !defaultLang || article.lang === defaultLang) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return article.lang.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadArticles() {
|
async function loadArticles() {
|
||||||
loadState.value = 'loading'
|
loadState.value = 'loading'
|
||||||
showSkeleton.value = false
|
showSkeleton.value = false
|
||||||
@@ -102,7 +136,7 @@ async function loadArticles() {
|
|||||||
}, SKELETON_DELAY_MS)
|
}, SKELETON_DELAY_MS)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await listArticles({
|
const result = await listStories({
|
||||||
page: 1,
|
page: 1,
|
||||||
size: RECENT_LIST_SIZE,
|
size: RECENT_LIST_SIZE,
|
||||||
sort: 'updated_at:desc',
|
sort: 'updated_at:desc',
|
||||||
@@ -110,6 +144,7 @@ async function loadArticles() {
|
|||||||
articles.value = result.items
|
articles.value = result.items
|
||||||
counts.value = result.counts
|
counts.value = result.counts
|
||||||
loadState.value = result.counts.all === 0 ? 'empty' : 'default'
|
loadState.value = result.counts.all === 0 ? 'empty' : 'default'
|
||||||
|
void loadDefaultLanguages(result.items)
|
||||||
} catch {
|
} catch {
|
||||||
loadState.value = 'error'
|
loadState.value = 'error'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -277,6 +312,12 @@ onMounted(loadArticles)
|
|||||||
>
|
>
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="languageCode(article)"
|
||||||
|
data-testid="language-code"
|
||||||
|
class="border border-accented px-1.5 font-mono text-xs text-highlighted"
|
||||||
|
>{{ languageCode(article) }}</span
|
||||||
|
>
|
||||||
<ArticleStatusBadge :online="article.is_online" />
|
<ArticleStatusBadge :online="article.is_online" />
|
||||||
<span class="hidden w-27.5 text-right text-muted lg:inline">
|
<span class="hidden w-27.5 text-right text-muted lg:inline">
|
||||||
{{ formatRelativeDate(article.updated_at) }}
|
{{ formatRelativeDate(article.updated_at) }}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import CodeText from '@/components/CodeText.vue'
|
|||||||
import DashboardShell from '@/components/DashboardShell.vue'
|
import DashboardShell from '@/components/DashboardShell.vue'
|
||||||
import EndpointCard from '@/components/EndpointCard.vue'
|
import EndpointCard from '@/components/EndpointCard.vue'
|
||||||
import IntegrationImages from '@/components/IntegrationImages.vue'
|
import IntegrationImages from '@/components/IntegrationImages.vue'
|
||||||
|
import IntegrationLanguages from '@/components/IntegrationLanguages.vue'
|
||||||
import SectionIndex from '@/components/SectionIndex.vue'
|
import SectionIndex from '@/components/SectionIndex.vue'
|
||||||
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
|
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
|
||||||
import {
|
import {
|
||||||
@@ -50,13 +51,20 @@ const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.
|
|||||||
|
|
||||||
const { before: groupsBeforeImages, after: groupsAfterImages } = groupsAroundImages(API_GROUPS)
|
const { before: groupsBeforeImages, after: groupsAfterImages } = groupsAroundImages(API_GROUPS)
|
||||||
|
|
||||||
|
const LANGUAGES_AFTER_GROUP = 'seo'
|
||||||
|
|
||||||
function groupSection(group: ApiGroup) {
|
function groupSection(group: ApiGroup) {
|
||||||
return { id: group.key, label: translate(group.title) }
|
return { id: group.key, label: translate(group.title) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = computed(() => [
|
const sections = computed(() => [
|
||||||
{ id: 'auth', label: t('Authentication') },
|
{ id: 'auth', label: t('Authentication') },
|
||||||
...groupsBeforeImages.map(groupSection),
|
...groupsBeforeImages.flatMap((group) => [
|
||||||
|
groupSection(group),
|
||||||
|
...(group.key === LANGUAGES_AFTER_GROUP
|
||||||
|
? [{ id: 'languages', label: t('Languages') }]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
{ id: 'images', label: t('Images and variants') },
|
{ id: 'images', label: t('Images and variants') },
|
||||||
...groupsAfterImages.map(groupSection),
|
...groupsAfterImages.map(groupSection),
|
||||||
{ id: 'session', label: t('Session only') },
|
{ id: 'session', label: t('Session only') },
|
||||||
@@ -166,7 +174,12 @@ const fieldTypeColumns = computed(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
const pageBlocks = [
|
const pageBlocks = [
|
||||||
...groupsBeforeImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
|
...groupsBeforeImages.flatMap((group) => [
|
||||||
|
{ kind: 'group' as const, key: group.key, group },
|
||||||
|
...(group.key === LANGUAGES_AFTER_GROUP
|
||||||
|
? [{ kind: 'languages' as const, key: 'languages', group: null }]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
{ kind: 'images' as const, key: 'images', group: null },
|
{ kind: 'images' as const, key: 'images', group: null },
|
||||||
...groupsAfterImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
|
...groupsAfterImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
|
||||||
]
|
]
|
||||||
@@ -524,6 +537,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
|||||||
|
|
||||||
<template v-for="block in pageBlocks" :key="block.key">
|
<template v-for="block in pageBlocks" :key="block.key">
|
||||||
<IntegrationImages v-if="block.kind === 'images'" :origin="origin" />
|
<IntegrationImages v-if="block.kind === 'images'" :origin="origin" />
|
||||||
|
<IntegrationLanguages v-else-if="block.kind === 'languages'" />
|
||||||
<section
|
<section
|
||||||
v-else
|
v-else
|
||||||
:data-anchor="block.group.key"
|
:data-anchor="block.group.key"
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { initializeLocale } from '@/i18n'
|
||||||
|
import { failWith, callsTo, press, typeInto, unmountRoute } from './localizationKit'
|
||||||
|
import { articleOf, mountEditor, IDS, type EditorFixture } from './editorKit'
|
||||||
|
import { openPanel, panelRoot } from './seoTestKit'
|
||||||
|
import { textOf } from './metaTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
|
||||||
|
const SLUG = 'comment-nous-chiffrons-une-renovation'
|
||||||
|
const SITE = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Journal',
|
||||||
|
key: 'journal',
|
||||||
|
site_name: 'Journal',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: 'Renovation notes.',
|
||||||
|
base_url: 'https://journal.example.com',
|
||||||
|
default_social_image_id: null,
|
||||||
|
default_robots: 'index',
|
||||||
|
locale: 'en-GB',
|
||||||
|
is_default: true,
|
||||||
|
article_count: 4,
|
||||||
|
public_url_pattern: PATTERN,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALTERNATES = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
lang: 'en',
|
||||||
|
story_id: 5,
|
||||||
|
slug: 'how-we-price-a-bathroom-renovation',
|
||||||
|
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
lang: 'de',
|
||||||
|
story_id: 5,
|
||||||
|
slug: 'so-kalkulieren-wir-eine-badsanierung',
|
||||||
|
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolved(overrides: Record<string, unknown> = {}, fallbacks = {}) {
|
||||||
|
return {
|
||||||
|
title: 'Couts de renovation | Journal',
|
||||||
|
description: '',
|
||||||
|
canonical_url: `https://journal.example.com/fr/${SLUG}`,
|
||||||
|
robots: 'index, follow',
|
||||||
|
og: {
|
||||||
|
type: 'article',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
image: '',
|
||||||
|
image_alt: '',
|
||||||
|
url: '',
|
||||||
|
site_name: 'Journal',
|
||||||
|
locale: 'fr_FR',
|
||||||
|
locale_alternates: [],
|
||||||
|
},
|
||||||
|
twitter: { card: 'summary' },
|
||||||
|
image: { url: '', alt: '', source: 'none' },
|
||||||
|
authors: [],
|
||||||
|
published_at: null,
|
||||||
|
modified_at: '2026-09-26T11:02:00Z',
|
||||||
|
fallbacks: {
|
||||||
|
title: 'title',
|
||||||
|
description: 'none',
|
||||||
|
image: 'none',
|
||||||
|
canonical: 'pattern',
|
||||||
|
site_text_skipped: true,
|
||||||
|
...fallbacks,
|
||||||
|
},
|
||||||
|
json_ld: {},
|
||||||
|
lang: 'fr',
|
||||||
|
alternates: ALTERNATES,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(
|
||||||
|
site: Record<string, unknown> = {},
|
||||||
|
seo: unknown = resolved(),
|
||||||
|
options: EditorFixture = {},
|
||||||
|
): EditorFixture {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
extra: {
|
||||||
|
'GET /v1/site/': [{ ...SITE, ...site }],
|
||||||
|
'GET /v1/article/12': { ...articleOf(options), slug: SLUG },
|
||||||
|
'GET /v1/article/12/seo': seo,
|
||||||
|
...options.extra,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await unmountRoute()
|
||||||
|
localStorage.clear()
|
||||||
|
initializeLocale()
|
||||||
|
})
|
||||||
|
|
||||||
|
function slugDialog() {
|
||||||
|
return Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"]')).at(-1)!
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialogText() {
|
||||||
|
return textOf(slugDialog())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSeoPanel(setup: EditorFixture) {
|
||||||
|
await mountEditor(setup)
|
||||||
|
const panel = await openPanel()
|
||||||
|
await flushPromises()
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Search and sharing: French (loc.seo.noFallback)', () => {
|
||||||
|
it('shows the heading, the scope, the canonical, the alternates, the social locale and the slug', async () => {
|
||||||
|
const panel = await openSeoPanel(fixture())
|
||||||
|
|
||||||
|
const content = textOf(panel)
|
||||||
|
expect(content).toContain('Search and sharing · French')
|
||||||
|
expect(content).toContain('Applies only to the French version. Page language: fr.')
|
||||||
|
expect(content).toContain('Canonical')
|
||||||
|
expect(content).toContain(`https://journal.example.com/fr/${SLUG}`)
|
||||||
|
expect(content).toContain(
|
||||||
|
`Built from the Public URL pattern ${PATTERN}. Each language points to itself unless you set a canonical override.`,
|
||||||
|
)
|
||||||
|
expect(content).toContain('Alternate languages')
|
||||||
|
expect(content).toContain('Published versions in enabled languages only.')
|
||||||
|
expect(panel.querySelectorAll('[data-testid="seo-alternate-row"]')).toHaveLength(2)
|
||||||
|
expect(content).toContain(
|
||||||
|
'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
|
||||||
|
)
|
||||||
|
expect(content).toContain('Drafts and the disabled Spanish language are left out.')
|
||||||
|
expect(content).toContain('Social locale')
|
||||||
|
expect(content).toContain('og:locale')
|
||||||
|
expect(content).toContain('Set: fr_FR')
|
||||||
|
expect(content).toContain(
|
||||||
|
'Set by an owner for French in Journal languages. A region is never guessed from the language.',
|
||||||
|
)
|
||||||
|
expect(content).toContain('Address (slug)')
|
||||||
|
expect(content).toContain(
|
||||||
|
'Unique within Journal and French. Editing the title never changes the address.',
|
||||||
|
)
|
||||||
|
expect(content).toContain('Edit slug')
|
||||||
|
expect(content).toContain('Editing the title never changes the slug. Only Edit slug does.')
|
||||||
|
expect(content).toContain('Page title')
|
||||||
|
expect(content).toContain('Fallback: French title')
|
||||||
|
expect(content).toContain(
|
||||||
|
'No fallback. Empty. English site default not used. The tag is left out.',
|
||||||
|
)
|
||||||
|
expect(content).toContain('Image alternative text')
|
||||||
|
expect(content).toContain('Written per language.')
|
||||||
|
const address = panel.querySelector<HTMLInputElement>('input[readonly]')
|
||||||
|
expect(address?.value).toBe(SLUG)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Public URL not configured with no canonical, no alternates and a link to the setting', async () => {
|
||||||
|
const panel = await openSeoPanel(
|
||||||
|
fixture(
|
||||||
|
{ public_url_pattern: null },
|
||||||
|
resolved({ canonical_url: `https://journal.example.com/${SLUG}`, alternates: [] }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = textOf(panel)
|
||||||
|
expect(content).toContain('Public URL not configured')
|
||||||
|
expect(content).toContain(
|
||||||
|
'Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work.',
|
||||||
|
)
|
||||||
|
expect(panel.querySelector('[data-testid="seo-canonical-card"]')).toBeNull()
|
||||||
|
expect(panel.querySelector('[data-testid="seo-alternates-card"]')).toBeNull()
|
||||||
|
await press('Open Public URL settings', panel)
|
||||||
|
await flushPromises()
|
||||||
|
expect(window.location.pathname + window.location.search).not.toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Social locale not set when nothing resolves', async () => {
|
||||||
|
const panel = await openSeoPanel(
|
||||||
|
fixture(
|
||||||
|
{},
|
||||||
|
resolved({
|
||||||
|
og: { ...resolved().og, locale: '' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = textOf(panel)
|
||||||
|
expect(content).toContain('Social locale not set')
|
||||||
|
expect(content).toContain(
|
||||||
|
'French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says a canonical override was set by hand', async () => {
|
||||||
|
const panel = await openSeoPanel(
|
||||||
|
fixture(
|
||||||
|
{},
|
||||||
|
resolved(
|
||||||
|
{ canonical_url: 'https://other.example/a' },
|
||||||
|
{ canonical: 'canonical_url' },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(textOf(panel)).toContain('https://other.example/a')
|
||||||
|
expect(textOf(panel)).toContain('Set by hand as a canonical override on this version.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists only the published alternates for a draft', async () => {
|
||||||
|
const panel = await openSeoPanel(fixture({}, resolved({ alternates: [ALTERNATES[0]] })))
|
||||||
|
|
||||||
|
expect(panel.querySelectorAll('[data-testid="seo-alternate-row"]')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the current panel on a single-language site', async () => {
|
||||||
|
await mountEditor(
|
||||||
|
fixture({}, resolved(), {
|
||||||
|
languages: [{ lang: 'fr', versions: 1, published: 0 }],
|
||||||
|
translations: ['fr'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
const content = textOf(panel)
|
||||||
|
expect(content).not.toContain('Alternate languages')
|
||||||
|
expect(content).not.toContain('Social locale')
|
||||||
|
expect(content).not.toContain('Page language')
|
||||||
|
expect(content).toContain('Meta title')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Fallback labels follow the API', () => {
|
||||||
|
it.each([
|
||||||
|
['title', 'description', 'Fallback: French title'],
|
||||||
|
['title', 'excerpt', 'Fallback: French excerpt'],
|
||||||
|
['title', 'body', 'Fallback: French body'],
|
||||||
|
['title', 'site', 'Fallback: French site description'],
|
||||||
|
['title', 'none', 'No fallback'],
|
||||||
|
])('title %s and description %s read "%s"', async (title, description, label) => {
|
||||||
|
const panel = await openSeoPanel(
|
||||||
|
fixture({}, resolved({}, { title, description, site_text_skipped: false })),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(textOf(panel)).toContain(label)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the default language when the site text is withheld', async () => {
|
||||||
|
const panel = await openSeoPanel(
|
||||||
|
fixture({}, resolved({}, { description: 'none', site_text_skipped: true })),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(textOf(panel)).toContain('Tag left out')
|
||||||
|
expect(textOf(panel)).toContain('English site default not used.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Uses site text for a version in the default language', async () => {
|
||||||
|
const panel = await openSeoPanel(
|
||||||
|
fixture({}, resolved({}, { description: 'site', site_text_skipped: false })),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(textOf(panel)).toContain('Uses site text')
|
||||||
|
expect(textOf(panel)).toContain('Fallback: French site description')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edit slug (loc.slug.published)', () => {
|
||||||
|
async function openDialog(setup: EditorFixture) {
|
||||||
|
const panel = await openSeoPanel(setup)
|
||||||
|
await press('Edit slug', panel)
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
it('warns about old links when the version is published', async () => {
|
||||||
|
await openDialog(fixture({}, resolved(), { online: { fr: true } }))
|
||||||
|
|
||||||
|
const content = dialogText()
|
||||||
|
expect(content).toContain('Edit slug for the French version')
|
||||||
|
expect(content).toContain('The address changes for French only.')
|
||||||
|
expect(content).toContain('This version is published')
|
||||||
|
expect(content).toContain(
|
||||||
|
'Links to the old address will stop working. RenewCMS does not create redirects.',
|
||||||
|
)
|
||||||
|
expect(content).toContain('Editing the title never changes the slug.')
|
||||||
|
expect(content).toContain('Cancel')
|
||||||
|
expect(content).toContain('Change address')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no warning for a draft', async () => {
|
||||||
|
await openDialog(fixture())
|
||||||
|
|
||||||
|
expect(dialogText()).not.toContain('This version is published')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the slug and the revision and closes with a toast', async () => {
|
||||||
|
await openDialog(
|
||||||
|
fixture({}, resolved(), {
|
||||||
|
extra: {
|
||||||
|
'PATCH /v1/article/12': {
|
||||||
|
...articleOf({}),
|
||||||
|
slug: 'nouveau-titre',
|
||||||
|
revision: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const input = slugDialog().querySelector<HTMLInputElement>('input')!
|
||||||
|
await typeInto(input, 'nouveau-titre')
|
||||||
|
|
||||||
|
await press('Change address', slugDialog())
|
||||||
|
|
||||||
|
expect(callsTo('PATCH', '/v1/article/12').at(-1)?.body).toEqual({
|
||||||
|
slug: 'nouveau-titre',
|
||||||
|
if_revision: 3,
|
||||||
|
})
|
||||||
|
expect(document.querySelector('#edit-slug-input')).toBeNull()
|
||||||
|
expect(IDS.fr).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[409, 'slug_taken', 'The slug "nouveau-titre" is already used on Journal.'],
|
||||||
|
[400, 'invalid_slug', 'Lowercase letters, numbers and hyphens. Unique on Journal.'],
|
||||||
|
])('keeps the dialog open on %s %s', async (status, code, message) => {
|
||||||
|
await openDialog(
|
||||||
|
fixture({}, resolved(), { extra: { 'PATCH /v1/article/12': failWith(status, code) } }),
|
||||||
|
)
|
||||||
|
const input = slugDialog().querySelector<HTMLInputElement>('input')!
|
||||||
|
await typeInto(input, 'nouveau-titre')
|
||||||
|
|
||||||
|
await press('Change address', slugDialog())
|
||||||
|
|
||||||
|
expect(dialogText()).toContain(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the conflict dialog on a revision conflict', async () => {
|
||||||
|
await openDialog(
|
||||||
|
fixture({}, resolved(), {
|
||||||
|
extra: {
|
||||||
|
'PATCH /v1/article/12': failWith(409, 'revision_conflict'),
|
||||||
|
'GET /v1/article/12?format=html': articleOf({}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const input = slugDialog().querySelector<HTMLInputElement>('input')!
|
||||||
|
await typeInto(input, 'nouveau-titre')
|
||||||
|
|
||||||
|
await press('Change address', slugDialog())
|
||||||
|
|
||||||
|
expect(dialogText()).not.toContain('Edit slug for the French version')
|
||||||
|
expect(panelRoot()).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,11 +8,11 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
import type { ActivityItem } from '@/lib/activity'
|
import type { ActivityItem } from '@/lib/activity'
|
||||||
import HomeView from '../HomeView.vue'
|
import HomeView from '../HomeView.vue'
|
||||||
import { anonymous, makeItem, target } from './activityFixtures'
|
import { anonymous, makeItem, target } from './activityFixtures'
|
||||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
import { storyEnvelope, storyListRoute } from './articleEnvelope'
|
||||||
import { accessFor } from '@/test/access'
|
import { accessFor } from '@/test/access'
|
||||||
|
|
||||||
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
|
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
|
||||||
const ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
|
const ARTICLES_ROUTE = storyListRoute({ size: 5, sort: 'updated_at:desc' })
|
||||||
|
|
||||||
type Handler = () => Response | Promise<Response>
|
type Handler = () => Response | Promise<Response>
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ describe('Home, owner or admin, the last five events with the actor name', () =>
|
|||||||
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
|
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
|
||||||
await mountHome(role, {
|
await mountHome(role, {
|
||||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(card().find('h2').text()).toBe('Recent activity')
|
expect(card().find('h2').text()).toBe('Recent activity')
|
||||||
@@ -127,7 +127,7 @@ describe('Home, regular user, own events only and no actor name', () => {
|
|||||||
it.each([['editor' as const]])('shows only the time for %s', async (role) => {
|
it.each([['editor' as const]])('shows only the time for %s', async (role) => {
|
||||||
await mountHome(role as undefined, {
|
await mountHome(role as undefined, {
|
||||||
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
|
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(card().find('h2').text()).toBe('Your recent activity')
|
expect(card().find('h2').text()).toBe('Your recent activity')
|
||||||
@@ -148,7 +148,7 @@ describe('Home rows, failure and security markers', () => {
|
|||||||
]
|
]
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page(items),
|
[ACTIVITY_ROUTE]: () => page(items),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const marks = rows().map((row) => ({
|
const marks = rows().map((row) => ({
|
||||||
@@ -179,7 +179,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
|
|||||||
]
|
]
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page(items),
|
[ACTIVITY_ROUTE]: () => page(items),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(rows()[0]!.text()).toContain('article.archive')
|
expect(rows()[0]!.text()).toContain('article.archive')
|
||||||
@@ -190,7 +190,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
|
|||||||
it('has no button or link inside a row and keeps the route on a click', async () => {
|
it('has no button or link inside a row and keeps the route on a click', async () => {
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page(recentItems(2)),
|
[ACTIVITY_ROUTE]: () => page(recentItems(2)),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const row = rows()[0]!
|
const row = rows()[0]!
|
||||||
@@ -209,7 +209,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
|
|||||||
]
|
]
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page(items),
|
[ACTIVITY_ROUTE]: () => page(items),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain(
|
expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain(
|
||||||
@@ -230,7 +230,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
|
|||||||
new Promise((done) => {
|
new Promise((done) => {
|
||||||
resolve = () => done(page(recentItems(5)))
|
resolve = () => done(page(recentItems(5)))
|
||||||
}),
|
}),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(card().attributes('aria-busy')).toBeUndefined()
|
expect(card().attributes('aria-busy')).toBeUndefined()
|
||||||
@@ -253,7 +253,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
|
|||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
await vi.advanceTimersByTimeAsync(200)
|
await vi.advanceTimersByTimeAsync(200)
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@ describe('Home card, error shows a retryable alert that leaves the page alone',
|
|||||||
attempt += 1
|
attempt += 1
|
||||||
return attempt === 1 ? (failure() as Response) : page(recentItems(2))
|
return attempt === 1 ? (failure() as Response) : page(recentItems(2))
|
||||||
},
|
},
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const alert = card().find('[role="alert"]')
|
const alert = card().find('[role="alert"]')
|
||||||
@@ -299,7 +299,7 @@ describe('Home card, empty when there are no events yet', () => {
|
|||||||
])('says so for %s', async (role, text) => {
|
])('says so for %s', async (role, text) => {
|
||||||
await mountHome(role as 'owner', {
|
await mountHome(role as 'owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page([], 0),
|
[ACTIVITY_ROUTE]: () => page([], 0),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const empty = card().find('[data-testid="recent-activity-empty"]')
|
const empty = card().find('[data-testid="recent-activity-empty"]')
|
||||||
@@ -323,7 +323,7 @@ describe('Home card and article list are independent', () => {
|
|||||||
it('shows the articles empty state and the card error together', async () => {
|
it('shows the articles empty state and the card error together', async () => {
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => response(500),
|
[ACTIVITY_ROUTE]: () => response(500),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope([], { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope([], { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper!.text()).toContain('Write your first article')
|
expect(wrapper!.text()).toContain('Write your first article')
|
||||||
@@ -335,7 +335,7 @@ describe('Home layout puts Recent activity full width below Recently updated', (
|
|||||||
it('renders the section after the recent list inside the same single column', async () => {
|
it('renders the section after the recent list inside the same single column', async () => {
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const column = card().element.parentElement!
|
const column = card().element.parentElement!
|
||||||
@@ -351,7 +351,7 @@ describe('Home card, the request URL', () => {
|
|||||||
it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
|
it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
|
const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
|
||||||
@@ -363,7 +363,7 @@ describe('Home card, "View all" opens the activity page', () => {
|
|||||||
it('navigates to /activity', async () => {
|
it('navigates to /activity', async () => {
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const push = vi.spyOn(router, 'push')
|
const push = vi.spyOn(router, 'push')
|
||||||
@@ -380,7 +380,7 @@ describe('Home card, the French interface', () => {
|
|||||||
setLocale('fr')
|
setLocale('fr')
|
||||||
await mountHome('owner', {
|
await mountHome('owner', {
|
||||||
[ACTIVITY_ROUTE]: () => page([], 0),
|
[ACTIVITY_ROUTE]: () => page([], 0),
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(card().find('h2').text()).toBe('Activité récente')
|
expect(card().find('h2').text()).toBe('Activité récente')
|
||||||
@@ -392,7 +392,7 @@ describe('Home card, the French interface', () => {
|
|||||||
describe('Home card, not shown without activity.view', () => {
|
describe('Home card, not shown without activity.view', () => {
|
||||||
it('renders no recent activity card and makes no activity request', async () => {
|
it('renders no recent activity card and makes no activity request', async () => {
|
||||||
await mountHome(undefined, {
|
await mountHome(undefined, {
|
||||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(card().exists()).toBe(false)
|
expect(card().exists()).toBe(false)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||||
import HomeView from '../HomeView.vue'
|
import HomeView from '../HomeView.vue'
|
||||||
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
|
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
|
||||||
import { articleEnvelope, articleListUrl } from './articleEnvelope'
|
import { storyEnvelope, storyListUrl } from './articleEnvelope'
|
||||||
import { accessFor } from '@/test/access'
|
import { accessFor } from '@/test/access'
|
||||||
|
|
||||||
function mockResponse(status: number, body: unknown = null) {
|
function mockResponse(status: number, body: unknown = null) {
|
||||||
@@ -37,8 +37,8 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
|
|||||||
session.session = { verified: true, username: 'marta', access: accessFor(role) }
|
session.session = { verified: true, username: 'marta', access: accessFor(role) }
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
global.fetch = vi.fn((url: string) => {
|
global.fetch = vi.fn((url: string) => {
|
||||||
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
|
if (url === storyListUrl({ size: 5, sort: 'updated_at:desc' })) {
|
||||||
return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
|
return Promise.resolve(mockResponse(200, storyEnvelope([], { size: 5 })))
|
||||||
}
|
}
|
||||||
if (url === '/v1/system/update') {
|
if (url === '/v1/system/update') {
|
||||||
return Promise.resolve(mockResponse(200, status))
|
return Promise.resolve(mockResponse(200, status))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
startable,
|
startable,
|
||||||
verifying,
|
verifying,
|
||||||
} from './updateTestKit'
|
} from './updateTestKit'
|
||||||
import { articleEnvelope, articleListUrl } from './articleEnvelope'
|
import { storyEnvelope, storyListUrl } from './articleEnvelope'
|
||||||
import { accessFor } from '@/test/access'
|
import { accessFor } from '@/test/access'
|
||||||
|
|
||||||
function mockResponse(status: number, body: unknown = null) {
|
function mockResponse(status: number, body: unknown = null) {
|
||||||
@@ -37,8 +37,8 @@ async function mountHome(role: UserRole, status: UpdateStatus) {
|
|||||||
session.session = { verified: true, username: 'marta', access: accessFor(role) }
|
session.session = { verified: true, username: 'marta', access: accessFor(role) }
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
global.fetch = vi.fn((url: string) => {
|
global.fetch = vi.fn((url: string) => {
|
||||||
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
|
if (url === storyListUrl({ size: 5, sort: 'updated_at:desc' })) {
|
||||||
return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
|
return Promise.resolve(mockResponse(200, storyEnvelope([], { size: 5 })))
|
||||||
}
|
}
|
||||||
if (url === '/v1/system/update') {
|
if (url === '/v1/system/update') {
|
||||||
return Promise.resolve(mockResponse(200, status))
|
return Promise.resolve(mockResponse(200, status))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import router from '@/router'
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import HomeView from '../HomeView.vue'
|
import HomeView from '../HomeView.vue'
|
||||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
import { storyEnvelope, storyListRoute } from './articleEnvelope'
|
||||||
|
|
||||||
let toastAdd: ReturnType<typeof vi.fn>
|
let toastAdd: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOME_ARTICLES_ROUTE = articleListRoute({
|
const HOME_ARTICLES_ROUTE = storyListRoute({
|
||||||
size: 5,
|
size: 5,
|
||||||
sort: 'updated_at:desc',
|
sort: 'updated_at:desc',
|
||||||
})
|
})
|
||||||
@@ -149,7 +149,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
|
|||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
resolveList = () =>
|
resolveList = () =>
|
||||||
resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
|
resolve(mockResponse(200, storyEnvelope(sixArticles, { size: 5 })))
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
|
|||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const wrapper = await mountHome({
|
const wrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () =>
|
||||||
Promise.resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 }))),
|
Promise.resolve(mockResponse(200, storyEnvelope(sixArticles, { size: 5 }))),
|
||||||
})
|
})
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(0)
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
@@ -186,8 +186,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
|
|||||||
describe('Default — the list loaded with a mix of online and draft articles', () => {
|
describe('Default — the list loaded with a mix of online and draft articles', () => {
|
||||||
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
|
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
|
||||||
const wrapper = await mountHome({
|
const wrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("Here's where things stand on your site.")
|
expect(wrapper.text()).toContain("Here's where things stand on your site.")
|
||||||
@@ -230,8 +229,7 @@ describe('A successful create hands off to the editor with a toast', () => {
|
|||||||
updated_at: '2026-01-01T00:00:00Z',
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
}
|
}
|
||||||
const wrapper = await mountHome({
|
const wrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
|
||||||
'POST /v1/article/?format=html': () => mockResponse(201, created),
|
'POST /v1/article/?format=html': () => mockResponse(201, created),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -267,7 +265,7 @@ describe('A successful create hands off to the editor with a toast', () => {
|
|||||||
describe('Empty, first run — zero articles', () => {
|
describe('Empty, first run — zero articles', () => {
|
||||||
it('shows the empty state and opens NewArticleModal', async () => {
|
it('shows the empty state and opens NewArticleModal', async () => {
|
||||||
const wrapper = await mountHome({
|
const wrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope([], { size: 5 })),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Welcome to RenewCMS.')
|
expect(wrapper.text()).toContain('Welcome to RenewCMS.')
|
||||||
@@ -311,7 +309,7 @@ describe('Retry re-issues the request and recovers into the Default state', () =
|
|||||||
const listCall = vi
|
const listCall = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(mockResponse(500))
|
.mockResolvedValueOnce(mockResponse(500))
|
||||||
.mockResolvedValueOnce(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
|
.mockResolvedValueOnce(mockResponse(200, storyEnvelope(sixArticles, { size: 5 })))
|
||||||
const wrapper = await mountHome({
|
const wrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: listCall,
|
[HOME_ARTICLES_ROUTE]: listCall,
|
||||||
})
|
})
|
||||||
@@ -330,7 +328,7 @@ describe('Retry re-issues the request and recovers into the Default state', () =
|
|||||||
|
|
||||||
describe('The list is fetched exactly once per navigation', () => {
|
describe('The list is fetched exactly once per navigation', () => {
|
||||||
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
|
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
|
||||||
const listCall = vi.fn(() => mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
|
const listCall = vi.fn(() => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })))
|
||||||
const wrapper = await mountHome({
|
const wrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: listCall,
|
[HOME_ARTICLES_ROUTE]: listCall,
|
||||||
})
|
})
|
||||||
@@ -353,8 +351,7 @@ describe('The greeting matches the hour of day', () => {
|
|||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
|
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
|
||||||
const wrapper = await mountHome({
|
const wrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain(`${greeting}, marta`)
|
expect(wrapper.text()).toContain(`${greeting}, marta`)
|
||||||
@@ -374,14 +371,13 @@ describe('The greeting is identical across every load state at a fixed hour', ()
|
|||||||
loadingWrapper.unmount()
|
loadingWrapper.unmount()
|
||||||
|
|
||||||
const defaultWrapper = await mountHome({
|
const defaultWrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
|
||||||
})
|
})
|
||||||
expect(defaultWrapper.text()).toContain('Good morning, marta')
|
expect(defaultWrapper.text()).toContain('Good morning, marta')
|
||||||
defaultWrapper.unmount()
|
defaultWrapper.unmount()
|
||||||
|
|
||||||
const emptyWrapper = await mountHome({
|
const emptyWrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope([], { size: 5 })),
|
||||||
})
|
})
|
||||||
expect(emptyWrapper.text()).toContain('Good morning, marta')
|
expect(emptyWrapper.text()).toContain('Good morning, marta')
|
||||||
emptyWrapper.unmount()
|
emptyWrapper.unmount()
|
||||||
@@ -408,8 +404,7 @@ describe('The greeting names the person by display name, else username', () => {
|
|||||||
session.session = { verified: true, username: 'marta', displayName }
|
session.session = { verified: true, username: 'marta', displayName }
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
global.fetch = fetchRouter({
|
global.fetch = fetchRouter({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
|
||||||
}) as unknown as typeof fetch
|
}) as unknown as typeof fetch
|
||||||
await router.push('/home')
|
await router.push('/home')
|
||||||
const wrapper = mount(HomeView, {
|
const wrapper = mount(HomeView, {
|
||||||
@@ -433,8 +428,7 @@ describe('The greeting names the person by display name, else username', () => {
|
|||||||
}
|
}
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
global.fetch = fetchRouter({
|
global.fetch = fetchRouter({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
|
||||||
}) as unknown as typeof fetch
|
}) as unknown as typeof fetch
|
||||||
await router.push('/home')
|
await router.push('/home')
|
||||||
const wrapper = mount(HomeView, {
|
const wrapper = mount(HomeView, {
|
||||||
@@ -460,8 +454,7 @@ describe('The greeting handles a missing username', () => {
|
|||||||
session.session = { verified: true }
|
session.session = { verified: true }
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
global.fetch = fetchRouter({
|
global.fetch = fetchRouter({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
|
||||||
}) as unknown as typeof fetch
|
}) as unknown as typeof fetch
|
||||||
await router.push('/home')
|
await router.push('/home')
|
||||||
const wrapper = mount(HomeView, {
|
const wrapper = mount(HomeView, {
|
||||||
@@ -492,13 +485,12 @@ describe('The "Connect your website" card renders in every state', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
|
|
||||||
const defaultWrapper = await mountHome({
|
const defaultWrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () =>
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
|
|
||||||
})
|
})
|
||||||
expect(defaultWrapper.text()).toContain('Connect your website')
|
expect(defaultWrapper.text()).toContain('Connect your website')
|
||||||
|
|
||||||
const emptyWrapper = await mountHome({
|
const emptyWrapper = await mountHome({
|
||||||
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope([], { size: 5 })),
|
||||||
})
|
})
|
||||||
expect(emptyWrapper.text()).toContain('Connect your website')
|
expect(emptyWrapper.text()).toContain('Connect your website')
|
||||||
|
|
||||||
@@ -508,3 +500,82 @@ describe('The "Connect your website" card renders in every state', () => {
|
|||||||
expect(errorWrapper.text()).toContain('Connect your website')
|
expect(errorWrapper.text()).toContain('Connect your website')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Home on a site with several languages', () => {
|
||||||
|
const base = {
|
||||||
|
body: '',
|
||||||
|
images: [],
|
||||||
|
is_online: true,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
site_id: 1,
|
||||||
|
}
|
||||||
|
const journal = [
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
id: 10,
|
||||||
|
title: 'How we price a bathroom renovation',
|
||||||
|
lang: 'en',
|
||||||
|
updated_at: '2026-01-06T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
id: 12,
|
||||||
|
title: 'Comment nous chiffrons une rénovation',
|
||||||
|
lang: 'fr',
|
||||||
|
updated_at: '2026-01-05T00:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const site = { id: 1, name: 'Journal', key: 'journal' }
|
||||||
|
const languages = {
|
||||||
|
default: 'en',
|
||||||
|
languages: [],
|
||||||
|
revision: '1',
|
||||||
|
needs_setup: false,
|
||||||
|
available: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function multilingualHandlers() {
|
||||||
|
return {
|
||||||
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(journal, { size: 5 })),
|
||||||
|
'GET /v1/site/': () => mockResponse(200, [site]),
|
||||||
|
'GET /v1/site/journal/languages': () => mockResponse(200, languages),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('asks for articles grouped by story and shows a code only for a non-default language', async () => {
|
||||||
|
const wrapper = await mountHome(multilingualHandlers())
|
||||||
|
|
||||||
|
const rows = wrapper.findAll('[data-testid="recent-list"] li')
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows[0]!.find('[data-testid="language-code"]').exists()).toBe(false)
|
||||||
|
expect(rows[1]!.find('[data-testid="language-code"]').text()).toBe('FR')
|
||||||
|
expect(HOME_ARTICLES_ROUTE).toContain('group=story')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows French titles with the code FR to a reader of French only', async () => {
|
||||||
|
const frenchOnly = journal.filter((item) => item.lang === 'fr')
|
||||||
|
const wrapper = await mountHome({
|
||||||
|
...multilingualHandlers(),
|
||||||
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(frenchOnly, { size: 5 })),
|
||||||
|
})
|
||||||
|
|
||||||
|
const row = wrapper.find('[data-testid="recent-list"] li')
|
||||||
|
expect(row.text()).toContain('Comment nous chiffrons une rénovation')
|
||||||
|
expect(row.find('[data-testid="language-code"]').text()).toBe('FR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts articles, not versions', async () => {
|
||||||
|
const wrapper = await mountHome(multilingualHandlers())
|
||||||
|
|
||||||
|
const tiles = wrapper.findAll('[data-testid="stat-tile"]')
|
||||||
|
expect(tiles[0]!.text()).toContain('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no code and makes no language request on a single-language site', async () => {
|
||||||
|
const wrapper = await mountHome({
|
||||||
|
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="language-code"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -382,11 +382,11 @@ describe('8d More endpoints than the cap', () => {
|
|||||||
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
|
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
|
||||||
'Section Errors. Status codes and messages',
|
'Section Errors. Status codes and messages',
|
||||||
)
|
)
|
||||||
expect(document.body.textContent).toContain('25 results')
|
expect(document.body.textContent).toContain('27 results')
|
||||||
expect(document.body.textContent).toContain(
|
expect(document.body.textContent).toContain(
|
||||||
'18 more endpoints. Keep typing to narrow the list.',
|
'20 more endpoints. Keep typing to narrow the list.',
|
||||||
)
|
)
|
||||||
expect(liveRegion()).toBe('25 results, showing 7')
|
expect(liveRegion()).toBe('27 results, showing 7')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const SECTION_LABELS = [
|
|||||||
'Authentication',
|
'Authentication',
|
||||||
'Articles',
|
'Articles',
|
||||||
'Search and sharing',
|
'Search and sharing',
|
||||||
|
'Languages',
|
||||||
'Content types',
|
'Content types',
|
||||||
'Entries',
|
'Entries',
|
||||||
'Built-in types',
|
'Built-in types',
|
||||||
@@ -151,7 +152,7 @@ describe('Default, every card collapsed', () => {
|
|||||||
const wrapper = await mountIntegration()
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
const index = wrapper.get('nav[aria-label="On this page"]')
|
const index = wrapper.get('nav[aria-label="On this page"]')
|
||||||
expect(index.findAll('button')).toHaveLength(14)
|
expect(index.findAll('button')).toHaveLength(15)
|
||||||
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
|
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
|
||||||
const jump = wrapper.get('button[aria-label="Jump to section"]')
|
const jump = wrapper.get('button[aria-label="Jump to section"]')
|
||||||
expect(jump.text()).toContain('Jump to')
|
expect(jump.text()).toContain('Jump to')
|
||||||
@@ -175,7 +176,7 @@ describe('Default, every card collapsed', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['articles', 'Articles', '8 routes', 8],
|
['articles', 'Articles', '10 routes', 10],
|
||||||
['seo', 'Search and sharing', '2 routes', 2],
|
['seo', 'Search and sharing', '2 routes', 2],
|
||||||
['ctypes', 'Content types', '5 routes', 5],
|
['ctypes', 'Content types', '5 routes', 5],
|
||||||
['entries', 'Entries', '5 routes', 5],
|
['entries', 'Entries', '5 routes', 5],
|
||||||
@@ -206,6 +207,7 @@ describe('Default, every card collapsed', () => {
|
|||||||
'auth',
|
'auth',
|
||||||
'articles',
|
'articles',
|
||||||
'seo',
|
'seo',
|
||||||
|
'languages',
|
||||||
'ctypes',
|
'ctypes',
|
||||||
'entries',
|
'entries',
|
||||||
'builtin',
|
'builtin',
|
||||||
@@ -1214,3 +1216,39 @@ describe('9j Navigation, scroll-spy on Content types', () => {
|
|||||||
host.remove()
|
host.remove()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('The language requests', () => {
|
||||||
|
it('shows the four cards and the real shapes of the public routes', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
|
const languages = wrapper.get('[data-testid="integration-languages"]')
|
||||||
|
const text = languages.text()
|
||||||
|
expect(text).toContain('1. Exact lookup: site + language + slug')
|
||||||
|
expect(text).toContain('GET /v1/public/site/{key}/lang/{lang}/article/{slug}')
|
||||||
|
expect(text).toContain('2. Resolve by article group, with fallback')
|
||||||
|
expect(text).toContain(
|
||||||
|
'GET /v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default',
|
||||||
|
)
|
||||||
|
expect(text).toContain('3. Errors')
|
||||||
|
expect(text).toContain('article_not_found')
|
||||||
|
expect(text).toContain('language_not_available')
|
||||||
|
expect(text).toContain('Existing, unchanged')
|
||||||
|
expect(text).toContain('GET /v1/article/{id}')
|
||||||
|
expect(text).not.toContain('Proposed')
|
||||||
|
expect(text).not.toContain('st_01')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no longer says that a title cannot change', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain("Titles can't be changed after creation")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists both public routes in the catalogue', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
|
const text = wrapper.text()
|
||||||
|
expect(text).toContain('/v1/public/site/{key}/lang/{lang}/article/{slug}')
|
||||||
|
expect(text).toContain('/v1/public/site/{key}/story/{story_id}')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -660,3 +660,54 @@ describe('The delete dialog', () => {
|
|||||||
expect(callsTo('DELETE', /\/v1\/media\/11$/)).toHaveLength(1)
|
expect(callsTo('DELETE', /\/v1\/media\/11$/)).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('The detail panel groups article versions under their article', () => {
|
||||||
|
const versioned = (id: number, title: string, story: number, lang: string): MediaUsageItem => ({
|
||||||
|
...article(id, title, true),
|
||||||
|
story_id: story,
|
||||||
|
lang,
|
||||||
|
})
|
||||||
|
const LOCALIZED: MediaUsageItem[] = [
|
||||||
|
versioned(10, 'How we price a bathroom renovation', 5, 'en'),
|
||||||
|
versioned(11, 'So kalkulieren wir eine Badsanierung', 5, 'de'),
|
||||||
|
versioned(12, 'Comment nous chiffrons une rénovation', 5, 'fr'),
|
||||||
|
versioned(20, 'A guide to lime plaster', 6, 'en'),
|
||||||
|
{ ...article(30, 'Legacy note', true), story_id: 30, lang: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('lists a story once, names its languages and counts articles', async () => {
|
||||||
|
await mountPage({ ...library(), usage: () => LOCALIZED }, '/media', undefined, {
|
||||||
|
app: true,
|
||||||
|
})
|
||||||
|
await open('kitchen-before.webp')
|
||||||
|
|
||||||
|
const rows = Array.from(document.body.querySelectorAll('[data-testid="usage-row"]'))
|
||||||
|
expect(rows.map(textOf)).toEqual([
|
||||||
|
expect.stringContaining('How we price a bathroom renovation'),
|
||||||
|
expect.stringContaining('A guide to lime plaster'),
|
||||||
|
expect.stringContaining('Legacy note'),
|
||||||
|
])
|
||||||
|
expect(textOf(rows[0]!)).toContain('English, German, French')
|
||||||
|
expect(textOf(document.body.querySelector('[data-testid="usage-articles"]')!)).toContain(
|
||||||
|
'Used in 3 articles',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists a one-language site exactly as before', async () => {
|
||||||
|
const single = [versioned(20, 'Open day', 1, 'en'), versioned(21, 'Lime', 2, 'en')]
|
||||||
|
await mountPage({ ...library(), usage: () => single }, '/media', undefined, { app: true })
|
||||||
|
await open('kitchen-before.webp')
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="usage-languages"]')).toBeNull()
|
||||||
|
expect(document.body.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps one row per item when the answer has no story_id', async () => {
|
||||||
|
await mountPage({ ...library(), usage: () => ARTICLES }, '/media', undefined, {
|
||||||
|
app: true,
|
||||||
|
})
|
||||||
|
await open('kitchen-before.webp')
|
||||||
|
|
||||||
|
expect(document.body.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -146,7 +146,7 @@ describe('Search and sharing group', () => {
|
|||||||
const text = sitemap.text()
|
const text = sitemap.text()
|
||||||
expect(text).toContain('A whole number from 1. Default 1.')
|
expect(text).toContain('A whole number from 1. Default 1.')
|
||||||
expect(text).toContain('25, 50, 100 or 500. Default 100.')
|
expect(text).toContain('25, 50, 100 or 500. Default 100.')
|
||||||
expect(text).toContain('{ url, modified_at, noindex }')
|
expect(text).toContain('{ url, modified_at, noindex, lang }')
|
||||||
expect(text).toContain('X-Total-Count')
|
expect(text).toContain('X-Total-Count')
|
||||||
expect(text).toContain('/v1/site/renew-blog/sitemap')
|
expect(text).toContain('/v1/site/renew-blog/sitemap')
|
||||||
expect(text).toContain(BODY_MISMATCH)
|
expect(text).toContain(BODY_MISMATCH)
|
||||||
@@ -165,6 +165,7 @@ describe('Search and sharing group', () => {
|
|||||||
'Authentication',
|
'Authentication',
|
||||||
'Articles',
|
'Articles',
|
||||||
'Search and sharing',
|
'Search and sharing',
|
||||||
|
'Languages',
|
||||||
'Content types',
|
'Content types',
|
||||||
'Entries',
|
'Entries',
|
||||||
'Built-in types',
|
'Built-in types',
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ describe('Existing screens are unaffected', () => {
|
|||||||
'localization-list',
|
'localization-list',
|
||||||
'localization-editor',
|
'localization-editor',
|
||||||
'localization-conflicts-moves',
|
'localization-conflicts-moves',
|
||||||
'localization-new-article',
|
'localization-new-article',
|
||||||
|
'localization-metadata-api',
|
||||||
|
'localization-surfaces',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { Page } from 'playwright'
|
||||||
|
import { accessFor } from '../src/test/access'
|
||||||
|
import { siteLanguagesBody } from '../src/test/storyLists'
|
||||||
|
import {
|
||||||
|
articleBody,
|
||||||
|
fails,
|
||||||
|
narrow,
|
||||||
|
ok,
|
||||||
|
press,
|
||||||
|
settle,
|
||||||
|
site,
|
||||||
|
state,
|
||||||
|
type Setup,
|
||||||
|
} from './editorLocalizationStates'
|
||||||
|
import type { ApiMock, VisualState } from './states'
|
||||||
|
|
||||||
|
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
|
||||||
|
const FRENCH_SLUG = 'comment-nous-chiffrons-une-renovation'
|
||||||
|
const FRENCH_TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
|
||||||
|
|
||||||
|
const alternates = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
lang: 'en',
|
||||||
|
story_id: 5,
|
||||||
|
slug: 'how-we-price-a-bathroom-renovation',
|
||||||
|
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
lang: 'de',
|
||||||
|
story_id: 5,
|
||||||
|
slug: 'so-kalkulieren-wir-eine-badsanierung',
|
||||||
|
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolved(locale: string, canonical = `https://journal.example.com/fr/${FRENCH_SLUG}`) {
|
||||||
|
return {
|
||||||
|
title: `${FRENCH_TITLE} | Journal`,
|
||||||
|
description: '',
|
||||||
|
canonical_url: canonical,
|
||||||
|
robots: 'index, follow',
|
||||||
|
og: {
|
||||||
|
type: 'article',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
image: '',
|
||||||
|
image_alt: '',
|
||||||
|
url: canonical,
|
||||||
|
site_name: 'Journal',
|
||||||
|
locale,
|
||||||
|
locale_alternates: [],
|
||||||
|
},
|
||||||
|
twitter: { card: 'summary' },
|
||||||
|
image: { url: '', alt: '', source: 'none' },
|
||||||
|
authors: [],
|
||||||
|
published_at: null,
|
||||||
|
modified_at: '2026-10-14T09:12:00Z',
|
||||||
|
fallbacks: {
|
||||||
|
title: 'title',
|
||||||
|
description: 'none',
|
||||||
|
image: 'none',
|
||||||
|
canonical: 'pattern',
|
||||||
|
site_text_skipped: true,
|
||||||
|
},
|
||||||
|
json_ld: {},
|
||||||
|
lang: 'fr',
|
||||||
|
alternates: canonical.includes('/fr/') ? alternates : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function languages(social: boolean) {
|
||||||
|
const body = siteLanguagesBody('journal')
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
languages: body.languages.map((row) =>
|
||||||
|
row.lang === 'fr'
|
||||||
|
? {
|
||||||
|
...row,
|
||||||
|
social_locale: social ? 'fr_FR' : null,
|
||||||
|
social_locale_state: social ? 'set' : 'not_set',
|
||||||
|
}
|
||||||
|
: row,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frenchArticle(setup: Setup) {
|
||||||
|
return { ...articleBody(setup, 'fr'), slug: FRENCH_SLUG, title: FRENCH_TITLE }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSeoPanel(page: Page) {
|
||||||
|
if (narrow(page)) {
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /^Details/ })
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
await page.getByRole('dialog').waitFor()
|
||||||
|
}
|
||||||
|
const body = page.locator('[data-testid="seo-body"]:visible')
|
||||||
|
if (!(await body.count())) {
|
||||||
|
await page.locator('[data-testid="seo-toggle"]:visible').first().click()
|
||||||
|
}
|
||||||
|
await page.locator('[data-testid="seo-edit"]:visible').first().click()
|
||||||
|
await page.locator('[data-testid="seo-language-cards"]').waitFor()
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seoState(
|
||||||
|
setup: Setup,
|
||||||
|
pattern: string | null,
|
||||||
|
locale: string,
|
||||||
|
social: boolean,
|
||||||
|
steps: (page: Page) => Promise<void> = openSeoPanel,
|
||||||
|
): VisualState {
|
||||||
|
const canonical = pattern
|
||||||
|
? `https://journal.example.com/fr/${FRENCH_SLUG}`
|
||||||
|
: `https://journal.example.com/${FRENCH_SLUG}`
|
||||||
|
const overrides: Record<string, ApiMock> = {
|
||||||
|
'GET /v1/site/': ok([{ ...site, public_url_pattern: pattern }]),
|
||||||
|
'GET /v1/auth/me': {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
id: 7,
|
||||||
|
is_verified: true,
|
||||||
|
username: 'marta',
|
||||||
|
email: 'marta@renew.example',
|
||||||
|
display_name: 'Marta Alves',
|
||||||
|
access: accessFor('owner'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'GET /v1/article/12': ok(frenchArticle(setup)),
|
||||||
|
'GET /v1/article/12/seo': ok(resolved(locale, canonical)),
|
||||||
|
'POST /v1/article/12/seo/check': ok({
|
||||||
|
status: 'ready',
|
||||||
|
suggestions: [],
|
||||||
|
slug: { available: true, suggestion: null },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return state({ languages: languages(social), ...setup }, steps, overrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditSlug(page: Page) {
|
||||||
|
await openSeoPanel(page)
|
||||||
|
await press(page, 'Edit slug')
|
||||||
|
await page.getByRole('dialog').last().waitFor()
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadataApiStates: Record<string, VisualState> = {
|
||||||
|
'loc.seo.noFallback': seoState({ suggestion: false }, PATTERN, 'fr_FR', true),
|
||||||
|
'loc.slug.published': seoState(
|
||||||
|
{ suggestion: false, online: { fr: true } },
|
||||||
|
PATTERN,
|
||||||
|
'fr_FR',
|
||||||
|
true,
|
||||||
|
openEditSlug,
|
||||||
|
),
|
||||||
|
'loc.url.unset': seoState({ suggestion: false }, null, 'fr_FR', true),
|
||||||
|
'loc.l10.noSocial': seoState({ suggestion: false }, PATTERN, '', false),
|
||||||
|
'loc.l10.api': {
|
||||||
|
path: '/integration',
|
||||||
|
api: {
|
||||||
|
'GET /v1/auth/me': {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
id: 7,
|
||||||
|
is_verified: true,
|
||||||
|
username: 'marta',
|
||||||
|
email: 'marta@renew.example',
|
||||||
|
display_name: 'Marta Alves',
|
||||||
|
access: accessFor('owner'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'GET /v1/image-variants': fails(500),
|
||||||
|
},
|
||||||
|
steps: async (page: Page) => {
|
||||||
|
await page.locator('[data-testid="integration-languages"]').scrollIntoViewIfNeeded()
|
||||||
|
await settle(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user