feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10165 -37

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter | | [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
| [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log | | [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
| [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas | | [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas |
| [docs/api/site-languages.md](docs/api/site-languages.md) | Site languages, first-time language setup and the public URL pattern |
| [docs/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering and known limitations | | [docs/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering and known limitations |
| [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache | | [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache |
| [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one | | [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one |
+11
View File
@@ -11,6 +11,7 @@ import (
domainImage "RenewCMS/internal/domain/image" domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite" domainInvite "RenewCMS/internal/domain/invite"
domainLanguage "RenewCMS/internal/domain/language" domainLanguage "RenewCMS/internal/domain/language"
domainLocalization "RenewCMS/internal/domain/localization"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge" domainPurge "RenewCMS/internal/domain/purge"
domainRole "RenewCMS/internal/domain/role" domainRole "RenewCMS/internal/domain/role"
@@ -70,6 +71,10 @@ type UseCases struct {
ListSitesUseCase *useCases.ListSitesUseCase ListSitesUseCase *useCases.ListSitesUseCase
CreateSiteUseCase *useCases.CreateSiteUseCase CreateSiteUseCase *useCases.CreateSiteUseCase
UpdateSiteUseCase *useCases.UpdateSiteUseCase UpdateSiteUseCase *useCases.UpdateSiteUseCase
GetSiteLanguagesUseCase *useCases.GetSiteLanguagesUseCase
UpdateSiteLanguagesUseCase *useCases.UpdateSiteLanguagesUseCase
GetLanguageSetupUseCase *useCases.GetLanguageSetupUseCase
ApplyLanguageSetupUseCase *useCases.ApplyLanguageSetupUseCase
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
DeleteSiteUseCase *useCases.DeleteSiteUseCase DeleteSiteUseCase *useCases.DeleteSiteUseCase
CreateContentTypeUseCase *useCases.CreateContentTypeUseCase CreateContentTypeUseCase *useCases.CreateContentTypeUseCase
@@ -178,6 +183,7 @@ type Repositories struct {
ArticleRepo domainArticle.Repository ArticleRepo domainArticle.Repository
SiteRepo domainSite.Repository SiteRepo domainSite.Repository
LanguageRepo domainLanguage.Repository LanguageRepo domainLanguage.Repository
LocalizationRepo domainLocalization.Repository
ContentTypeRepo domainContentType.Repository ContentTypeRepo domainContentType.Repository
EntryRepo domainEntry.Repository EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository AuthorRepo domainAuthor.Repository
@@ -327,6 +333,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
ArticleRepo: persistence.NewArticleRepository(db), ArticleRepo: persistence.NewArticleRepository(db),
SiteRepo: persistence.NewSiteRepository(db), SiteRepo: persistence.NewSiteRepository(db),
LanguageRepo: persistence.NewSiteLanguageRepository(db), LanguageRepo: persistence.NewSiteLanguageRepository(db),
LocalizationRepo: persistence.NewLocalizationRepository(db),
ContentTypeRepo: persistence.NewContentTypeRepository(db), ContentTypeRepo: persistence.NewContentTypeRepository(db),
EntryRepo: persistence.NewEntryRepository(db), EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db), AuthorRepo: persistence.NewAuthorRepository(db),
@@ -403,6 +410,10 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo), ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
GetSiteLanguagesUseCase: useCases.NewGetSiteLanguagesUseCase(repos.SiteRepo, repos.LocalizationRepo),
UpdateSiteLanguagesUseCase: useCases.NewUpdateSiteLanguagesUseCase(repos.Transactor, recorder),
GetLanguageSetupUseCase: useCases.NewGetLanguageSetupUseCase(repos.SiteRepo, repos.LocalizationRepo),
ApplyLanguageSetupUseCase: useCases.NewApplyLanguageSetupUseCase(repos.Transactor, recorder),
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder), SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder), DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now), CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
+164
View File
@@ -0,0 +1,164 @@
package language
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainLanguage "RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"github.com/go-chi/chi/v5"
)
type SiteLanguagesHandler struct {
GetUseCase *useCases.GetSiteLanguagesUseCase
UpdateUseCase *useCases.UpdateSiteLanguagesUseCase
}
func NewSiteLanguagesHandler(get *useCases.GetSiteLanguagesUseCase, update *useCases.UpdateSiteLanguagesUseCase) *SiteLanguagesHandler {
return &SiteLanguagesHandler{GetUseCase: get, UpdateUseCase: update}
}
type PutLanguage struct {
Lang string `json:"lang"`
Enabled *bool `json:"enabled" validate:"required"`
SocialLocale *string `json:"social_locale"`
}
type PutSiteLanguages struct {
Revision *string `json:"revision" validate:"required"`
Default *string `json:"default" validate:"required"`
Languages []PutLanguage `json:"languages" validate:"required,dive"`
}
func (p PutSiteLanguages) command(key string) useCases.UpdateSiteLanguagesCommand {
entries := make([]useCases.SiteLanguageEntry, 0, len(p.Languages))
for _, entry := range p.Languages {
entries = append(entries, useCases.SiteLanguageEntry{Lang: entry.Lang, Enabled: *entry.Enabled, SocialLocale: entry.SocialLocale})
}
return useCases.UpdateSiteLanguagesCommand{Key: key, Revision: *p.Revision, Default: *p.Default, Languages: entries}
}
func (h *SiteLanguagesHandler) Get(w http.ResponseWriter, r *http.Request) {
view, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if err != nil {
writeLocalizationError(w, err)
return
}
writeJSON(w, view)
}
func (h *SiteLanguagesHandler) Update(w http.ResponseWriter, r *http.Request) {
var request PutSiteLanguages
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
view, err := h.UpdateUseCase.Update(r.Context(), request.command(chi.URLParam(r, "key")))
if err != nil {
writeLocalizationError(w, err)
return
}
writeJSON(w, view)
}
type SetupHandler struct {
GetUseCase *useCases.GetLanguageSetupUseCase
ApplyUseCase *useCases.ApplyLanguageSetupUseCase
}
func NewSetupHandler(get *useCases.GetLanguageSetupUseCase, apply *useCases.ApplyLanguageSetupUseCase) *SetupHandler {
return &SetupHandler{GetUseCase: get, ApplyUseCase: apply}
}
type PostSetupSite struct {
Key string `json:"key" validate:"required"`
Lang string `json:"lang"`
}
type PostSetup struct {
Sites []PostSetupSite `json:"sites" validate:"required,dive"`
}
func (h *SetupHandler) Get(w http.ResponseWriter, r *http.Request) {
view, err := h.GetUseCase.Get()
if err != nil {
writeLocalizationError(w, err)
return
}
writeJSON(w, view)
}
func (h *SetupHandler) Apply(w http.ResponseWriter, r *http.Request) {
var request PostSetup
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
choices := make([]localization.SetupChoice, 0, len(request.Sites))
for _, entry := range request.Sites {
choices = append(choices, localization.SetupChoice{Key: entry.Key, Lang: entry.Lang})
}
result, err := h.ApplyUseCase.Apply(r.Context(), choices)
if err != nil {
writeLocalizationError(w, err)
return
}
writeJSON(w, result)
}
func writeJSON(w http.ResponseWriter, value any) {
body, err := json.Marshal(value)
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)
}
}
type localizationFailure struct {
sentinel error
status int
body string
}
var localizationFailures = []localizationFailure{
{site.ErrSiteNotFound, http.StatusNotFound, apperr.SiteNotFoundErrorMessage},
{localization.ErrSetupRequired, http.StatusConflict, apperr.SetupRequiredErrorMessage},
{localization.ErrLanguagesChanged, http.StatusConflict, apperr.LanguagesChangedErrorMessage},
{localization.ErrLanguageInUse, http.StatusConflict, apperr.LanguageInUseErrorMessage},
{localization.ErrSetupInProgress, http.StatusConflict, apperr.SetupInProgressErrorMessage},
{localization.ErrDefaultNotEnabled, http.StatusUnprocessableEntity, apperr.DefaultNotEnabledErrorMessage},
{localization.ErrNoEnabledLanguage, http.StatusUnprocessableEntity, apperr.NoEnabledLanguageErrorMessage},
{localization.ErrDuplicateLanguage, http.StatusUnprocessableEntity, apperr.DuplicateLanguageErrorMessage},
{localization.ErrInvalidSocialLocale, http.StatusUnprocessableEntity, apperr.InvalidSocialLocaleErrorMessage},
{localization.ErrSetupIncomplete, http.StatusUnprocessableEntity, apperr.SetupIncompleteErrorMessage},
{localization.ErrDuplicateSite, http.StatusUnprocessableEntity, apperr.DuplicateSiteErrorMessage},
{domainLanguage.ErrInvalidLanguage, http.StatusUnprocessableEntity, apperr.InvalidLanguageErrorMessage},
{domainLanguage.ErrUnsupportedLanguage, http.StatusUnprocessableEntity, apperr.UnsupportedLanguageErrorMessage},
}
func writeLocalizationError(w http.ResponseWriter, err error) {
for _, failure := range localizationFailures {
if errors.Is(err, failure.sentinel) {
http.Error(w, failure.body, failure.status)
return
}
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
+14
View File
@@ -71,6 +71,7 @@ type PatchSite struct {
SiteName *string `json:"site_name" validate:"omitempty,max=100"` SiteName *string `json:"site_name" validate:"omitempty,max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"` TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription *string `json:"default_description" validate:"omitempty,max=320"` DefaultDescription *string `json:"default_description" validate:"omitempty,max=320"`
PublicURLPattern optional[string] `json:"public_url_pattern"`
} }
func (p PatchSite) toPatch() domain.Patch { func (p PatchSite) toPatch() domain.Patch {
@@ -85,6 +86,7 @@ func (p PatchSite) toPatch() domain.Patch {
SiteName: p.SiteName, SiteName: p.SiteName,
TitleTemplate: p.TitleTemplate, TitleTemplate: p.TitleTemplate,
DefaultDescription: p.DefaultDescription, DefaultDescription: p.DefaultDescription,
PublicURLPattern: domain.Nullable[string]{Set: p.PublicURLPattern.Set, Value: p.PublicURLPattern.Value},
} }
} }
@@ -125,6 +127,18 @@ func writeSiteError(w http.ResponseWriter, err error) {
http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest) http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidLocale): case errors.Is(err, domain.ErrInvalidLocale):
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest) http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidPublicURLPattern):
http.Error(w, apperr.InvalidPublicURLPatternErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLScheme):
http.Error(w, apperr.PublicURLSchemeErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLHost):
http.Error(w, apperr.PublicURLHostErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLPlaceholder):
http.Error(w, apperr.PublicURLPlaceholderErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLNotEncoded):
http.Error(w, apperr.PublicURLNotEncodedErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLMissingSlug):
http.Error(w, apperr.PublicURLMissingSlugErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, image.ErrImageNotFound): case errors.Is(err, image.ErrImageNotFound):
http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest) http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrUseSetDefault): case errors.Is(err, domain.ErrUseSetDefault):
+19
View File
@@ -69,6 +69,25 @@ const DefaultSiteErrorMessage = "default_site"
const SiteKeyImmutableErrorMessage = "key_immutable" const SiteKeyImmutableErrorMessage = "key_immutable"
const InvalidSiteURLErrorMessage = "invalid_site_url" const InvalidSiteURLErrorMessage = "invalid_site_url"
const InvalidLocaleErrorMessage = "invalid_locale" const InvalidLocaleErrorMessage = "invalid_locale"
const InvalidPublicURLPatternErrorMessage = "invalid_pattern"
const PublicURLSchemeErrorMessage = "invalid_scheme"
const PublicURLHostErrorMessage = "invalid_host"
const PublicURLPlaceholderErrorMessage = "unknown_placeholder"
const PublicURLNotEncodedErrorMessage = "not_encoded"
const PublicURLMissingSlugErrorMessage = "missing_slug"
const SetupRequiredErrorMessage = "setup_required"
const LanguagesChangedErrorMessage = "languages_changed"
const LanguageInUseErrorMessage = "language_in_use"
const DefaultNotEnabledErrorMessage = "default_not_enabled"
const NoEnabledLanguageErrorMessage = "no_enabled_language"
const InvalidLanguageErrorMessage = "invalid_language"
const UnsupportedLanguageErrorMessage = "unsupported_language"
const DuplicateLanguageErrorMessage = "duplicate_language"
const InvalidSocialLocaleErrorMessage = "invalid_social_locale"
const SetupInProgressErrorMessage = "setup_in_progress"
const SetupIncompleteErrorMessage = "setup_incomplete"
const DuplicateSiteErrorMessage = "duplicate_site"
const SiteImageNotFoundErrorMessage = "image_not_found" const SiteImageNotFoundErrorMessage = "image_not_found"
const UseSetDefaultErrorMessage = "use_set_default" const UseSetDefaultErrorMessage = "use_set_default"
const InvalidReassignErrorMessage = "invalid_reassign" const InvalidReassignErrorMessage = "invalid_reassign"
+26
View File
@@ -11,6 +11,7 @@ import (
"RenewCMS/api/handlers/entry" "RenewCMS/api/handlers/entry"
"RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant" "RenewCMS/api/handlers/imagevariant"
languagehandler "RenewCMS/api/handlers/language"
"RenewCMS/api/handlers/llms" "RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media" "RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/ownership" "RenewCMS/api/handlers/ownership"
@@ -295,6 +296,8 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase) sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
siteLanguagesHandler := languagehandler.NewSiteLanguagesHandler(container.GetSiteLanguagesUseCase, container.UpdateSiteLanguagesUseCase)
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase) ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap) r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
@@ -305,6 +308,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", siteHandler.List) r.Get("/", siteHandler.List)
r.Get("/{key}/languages", siteLanguagesHandler.Get)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
@@ -317,12 +321,33 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create) r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update) r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault) r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault)
r.With(container.require(role.KeySettingsManageSites)).Put("/{key}/languages", siteLanguagesHandler.Update)
r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete) r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer) r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
}) })
return r return r
} }
func NewLanguageRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
setupHandler := languagehandler.NewSetupHandler(container.GetLanguageSetupUseCase, container.ApplyLanguageSetupUseCase)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySettingsManageSites))
r.Get("/setup", setupHandler.Get)
r.Post("/setup", setupHandler.Apply)
})
return r
}
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler { func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
@@ -586,6 +611,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
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))
r.Mount("/language", NewLanguageRouter(container, settings.TrustedProxies))
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies)) r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies)) r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies)) r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
+5 -5
View File
@@ -3,7 +3,7 @@
The full, searchable reference is the Integration page of the running app (`/integration`). It The full, searchable reference is the Integration page of the running app (`/integration`). It
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page, readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
[api/articles.md](api/articles.md), and API keys are covered in [api/api-keys.md](api/api-keys.md). [api/articles.md](api/articles.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
## Base URL ## Base URL
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`, The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
@@ -75,14 +75,14 @@ never changes the response or the change itself.
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | | `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` | | `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `users` | `user.register`, `user.verify`, `user.update_profile`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` | | `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete`, `site.languages_edit`, `site.language_setup` |
| `authors` | `author.create`, `author.edit`, `author.delete` | | `authors` | `author.create`, `author.edit`, `author.delete` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`, Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
`api_key` and `release` (id and name are the tag). `api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `public_url_pattern`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description`, `public_url_pattern` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
change that leaves a value as it was (same body, same publish state, same file name, same display change that leaves a value as it was (same body, same publish state, same file name, same display
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`); `merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
@@ -145,7 +145,7 @@ session." with a key. The role is checked before the body.
| --- | --- | | --- | --- |
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. | | `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. |
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. | | `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. | | `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`, `public_url_pattern` (see [api/site-languages.md](api/site-languages.md)). Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. | | `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. | | `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
+145
View File
@@ -0,0 +1,145 @@
# Site languages, language setup and public URL pattern
Contract for `features/site_languages.feature`, `features/language_setup.feature` and `features/site_public_url_pattern.feature`. The SPA mocks `/v1` from this page.
Errors are plain text whose body is exactly the code, with the status below. A body that cannot be decoded, or that lacks a required key, is 400 with the body-mismatch message ("The request cannot be processed due to a mismatch in the format of the body."). An anonymous caller is 401, a verified user without `settings.manage_sites` is 403 `missing_permission`. Writes need a real session; API keys are refused.
An owner is a verified user holding `settings.manage_sites`. A reader is any other verified user.
## Visibility exception (accepted)
`version_count` and `published_count` in `GET /v1/site/{key}/languages` are site-level aggregates of every non-deleted article (soft-deleted articles are not shown but still keep their language in use) of the language, drafts and offline ones included. They are visible to every signed-in reader, whatever that reader may open. This is a deliberate exception to the per-version visibility policy of `docs/design/localization-tables.md` ("Hidden versions"), accepted for slice 2. Later slices that expose titles or links of single versions must still filter by what the caller can read.
## Languages of a site
The supported tags are the list of `internal/domain/language` (`en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`). Tags are normalized: `pt-br` is stored as `pt-BR`.
### `GET /v1/site/{key}/languages`
Any verified user (session or API key). 404 `site_not_found`.
```json
{
"revision": "9f2c...",
"default": "en",
"needs_setup": false,
"languages": [
{"lang": "en", "enabled": true, "is_default": true, "version_count": 7, "published_count": 6, "social_locale": null, "social_locale_state": "not_set"},
{"lang": "fr", "enabled": true, "is_default": false, "version_count": 4, "published_count": 2, "social_locale": "fr_FR", "social_locale_state": "set"}
],
"available": [
{"lang": "pt", "substitute": null},
{"lang": "pt-PT", "substitute": "pt"}
]
}
```
- `revision` is an opaque digest of the rows (lang, default, enabled, social_locale). Counts do not change it. Send it back on `PUT`.
- `languages` lists the default first, then the other enabled languages by tag, then the disabled ones by tag.
- `social_locale_state` is `set` (an explicit `social_locale`), `derived` (no explicit value and the tag has a region: the effective value is the tag with an underscore, `en-GB` gives `en_GB`) or `not_set`. `social_locale` is only the explicit value, so the client computes the derived one from the tag.
- `available` lists the tags an owner can pick. A tag with a non-null `substitute` is not supported itself; only the broader tag is. The server refuses it with `unsupported_language`, so the client sends the substitute once the user confirms.
- `needs_setup` is true when the site has no confirmed default language. Then `default` is `null`, `languages` is `[]` and `PUT` answers 409 `setup_required`. Sites created after the migration have no language row and need setup too.
### `PUT /v1/site/{key}/languages`
Owner only. The whole set is replaced in one transaction under the site lock.
```json
{
"revision": "9f2c...",
"default": "en",
"languages": [
{"lang": "en", "enabled": true, "social_locale": null},
{"lang": "fr", "enabled": true, "social_locale": "fr_CA"}
]
}
```
`revision`, `default`, `languages` and each `enabled` are required (400 when absent). `social_locale` is optional and `null` means none. A language left out of the list is removed. The answer is 200 with the new read shape. Sending the state that is already stored changes nothing and keeps the revision.
Checks run in this order:
| Order | Status | Body | When |
| --- | --- | --- | --- |
| 1 | 404 | `site_not_found` | unknown key |
| 2 | 409 | `setup_required` | the site has no confirmed default (any body) |
| 3 | 409 | `languages_changed` | `revision` is not the current one; nothing is applied |
| 4 | 422 | `invalid_language` | a tag is not a language tag (`english`, empty) |
| 4 | 422 | `unsupported_language` | a well formed tag that is not supported (`xx`, `pt-PT`) |
| 4 | 422 | `duplicate_language` | the same tag twice, case ignored |
| 4 | 422 | `invalid_social_locale` | not `ll_RR` or `lll_RR` (letters, one underscore), empty string, or the language part differs from the tag's language |
| 5 | 422 | `no_enabled_language` | no language is enabled, `languages: []` included |
| 6 | 422 | `default_not_enabled` | `default` is not in the list, not enabled, or not a supported tag |
| 7 | 409 | `language_in_use` | a removed language still has an article, draft, offline, online or soft-deleted (the display count `version_count` stays non-deleted only) |
| any | 409 | `languages_changed` | the database could not lock the site in time because another change is running (lock contention); nothing is applied, reload and retry |
Entries are checked in list order for step 4. A rejected request changes nothing. Disabling a language never touches its articles, and no article changes `lang`, status or publication. A `social_locale` is stored as `language_REGION` (`fr_fr` becomes `fr_FR`). The language part rule only compares with the tag; matching `Site.Locale` is deferred to slice 6.
## First-time language setup
One global setup for every site with no confirmed default language. Owner only (`GET` as well: a reader is 403).
### `GET /v1/language/setup`
```json
{
"sites": [
{"key": "journal", "name": "Journal", "locale": "en", "article_count": 8, "suggestion": {"lang": "en", "substituted": false}},
{"key": "notes", "name": "Notes", "locale": null, "article_count": 3, "suggestion": null}
],
"available": [{"lang": "en", "substitute": null}]
}
```
`sites` lists the pending sites by id. `suggestion` comes from `Site.Locale` and keeps the region (`pt-BR` stays `pt-BR`); `substituted` is true when only the broader tag is supported (`fr-BE` gives `fr`). `suggestion` is `null` without a locale or without a supported language. An empty `sites` list means nothing is pending.
### `POST /v1/language/setup`
```json
{"sites": [{"key": "journal", "lang": "en"}, {"key": "notes", "lang": "en"}]}
```
200 with `{"assigned": 16, "sites": [{"key": "journal", "lang": "en", "assigned": 8}]}`. `assigned` counts the articles that got a language (soft-deleted ones included). `sites` lists the sites confirmed by this call. The default language is created enabled and confirmed, the seeded unconfirmed row is replaced, and every article without a language takes the tag. Nothing is translated, linked or moved. Sites that already have a confirmed default are skipped, whatever tag is asked for, so a retry answers 200 with `assigned: 0`.
| Status | Body | When |
| --- | --- | --- |
| 400 | body-mismatch message | no `sites` key or malformed JSON |
| 404 | `site_not_found` | a key does not exist |
| 409 | `setup_in_progress` | another setup holds the site locks (nothing applied) |
| 422 | `invalid_language` / `unsupported_language` | a bad `lang`, empty included |
| 422 | `duplicate_site` | the same key twice |
| 422 | `setup_incomplete` | a pending site is missing from the request |
| 500 | `Internal server error` | a write failed; everything is rolled back |
All sites are applied in one transaction. Site rows are locked in ascending id order without waiting; a busy lock is `setup_in_progress`. The pending list is read again after the locks and any site that became pending in between is locked too, until the list is stable, so a site created while a setup starts is never left half handled.
The 200 body also has `skipped`: the keys, sorted, of requested sites that already had a confirmed default and were left untouched (always present, `[]` when none). Their requested `lang` is ignored, never applied.
```json
{"assigned": 3, "sites": [{"key": "notes", "lang": "fr", "assigned": 3}], "skipped": ["journal"]}
```
## Lock contention
Every locking path of `PUT /v1/site/{key}/languages` and `POST /v1/language/setup` runs inside the transactor. A lock that cannot be taken in time (SQLite `SQLITE_BUSY`/`SQLITE_LOCKED`, MySQL 3572 and 1205, PostgreSQL `55P03`) is recognised once, by the database driver, and reaches the use case as `transaction.ErrLockContention`. The PUT answers 409 `languages_changed` and the setup answers 409 `setup_in_progress`. Nothing is applied and a retry is safe. It is never a 500.
## SQLite does not lock rows
The pinned SQLite driver (`github.com/glebarez/sqlite`) drops the `FOR UPDATE` clause: the `SELECT ... FOR UPDATE [NOWAIT]` that locks a site row on MySQL and PostgreSQL is a plain read on SQLite. SQLite instead serializes writers on the whole database: a second writer waits up to the 5 s busy timeout and a transaction that read before another one committed fails with `SQLITE_BUSY` when it tries to write. Both outcomes keep the data consistent and the busy failure is answered as lock contention above. `test/localization_concurrency_test.go` pins the statuses (200 or 409, never 500) for a PUT racing a site creation and for two simultaneous setups.
## Activity log
Both writes are recorded in the `sites` category, once after the commit and never for a read or a no-op:
| Action | Target | Changes and params |
| --- | --- | --- |
| `site.languages_edit` | the site | `default_language` (before, after) when the default changed, `languages` (before, after: tags sorted, `(off)` for disabled, `[social_locale]`). A PUT that changes nothing records nothing. |
| `site.language_setup` | one event per site confirmed by the POST | `default_language` ("" to the tag), param `assigned` (articles that got the language) |
A refused call is recorded as a failed event with a plain reason (for example "The languages changed since they were read."). An unknown site key is not recorded.
## Public URL pattern
`public_url_pattern` is a string or `null` on every site JSON (`GET /v1/site/`, `POST`, `PATCH`). It is read with the existing list and written with the existing `PATCH /v1/site/{id}` (owner). No new route. A `PATCH` without the key leaves it unchanged; `null` or `""` unsets it; surrounding spaces are trimmed.
Validation, in this order, answers 422 with the code as plain text: `invalid_pattern` (more than 512 characters), `invalid_scheme` (not `https://` or `http://`), `invalid_host` (no host, for example `https:///{slug}`), `unknown_placeholder` (anything between braces other than `{lang}` and `{slug}`, or a stray brace), `not_encoded` (a space, a non-ASCII character or other character that needs URL encoding; `%XX` sequences are fine), `missing_slug` (no `{slug}`). Repeated placeholders are allowed. Saving the pattern changes no article, canonical URL or public response, and is recorded in the activity log as the `public_url_pattern` field.
+177
View File
@@ -0,0 +1,177 @@
Feature: First-time language setup for existing articles
Existing articles have no language. One owner confirms a default language
for every site that has none, and all assignments apply in one transaction.
It labels articles. It never translates, links or moves them.
Background:
Given the language rules of features/article_localization_storage.feature apply
And error responses are plain text whose body is the snake_case code
And the setup page is global: its route is /settings/sites/:key/languages/setup, where :key is only the site it was opened from
And a site is "pending" when it has no confirmed default language
And these sites exist
| key | name | Locale | articles | state |
| journal | Journal | en | 8 | unconfirmed default row "en" |
| notes | Notes | null | 3 | no language row |
| archive | Archive | pt-BR | 5 | unconfirmed default row "pt-BR" |
| gallery | Gallery | en-GB | 0 | unconfirmed default row "en-GB" |
Scenario: Read what is pending
When an owner sends GET /v1/language/setup
Then the status is 200
And "sites" lists every pending site with key, name, locale, article_count and a suggestion
And the suggestion is { "lang": "pt-BR", "substituted": false } for archive and null for notes
And "available" lists the selectable tags like GET /v1/site/{key}/languages
And a site whose default is confirmed is not listed
Scenario Outline: Read access
When <caller> sends GET /v1/language/setup
Then the status is <status>
Examples:
| caller | status |
| an anonymous client | 401 |
| a reader | 403 |
| an owner | 200 |
Scenario: Apply for every pending site
When an owner sends POST /v1/language/setup with
| key | lang |
| journal | en |
| notes | en |
| archive | pt-BR |
| gallery | en-GB |
Then the status is 200
And every listed site has a confirmed default language row with the chosen tag, enabled
And all 16 articles carry the chosen tag of their site
And the body says 16 articles were assigned, per site
And every article keeps its id, title, body, slug, is_online, story_id and timestamps
And no story is merged, split or moved
And GET /v1/site/journal/languages has "needs_setup" false
Scenario: The owner may change a suggestion
When an owner confirms "archive" with "pt" instead of "pt-BR"
Then archive has one language row "pt", confirmed and default
And no row "pt-BR" remains
And its 5 articles carry "pt"
Scenario: A site with no articles still needs confirming
When an owner posts every pending site except "gallery"
Then the status is 422 and the body is "setup_incomplete"
And nothing is applied
Scenario Outline: Rows that are refused
When an owner posts <body>
Then the status is <status> and the body is "<code>"
And no site and no article has changed
Examples:
| body | status | code |
| notes with lang "english" | 422 | invalid_language |
| notes with lang "xx" | 422 | unsupported_language |
| notes with an empty lang | 422 | invalid_language |
| the same site key twice | 422 | duplicate_site |
| a site key "nowhere" | 404 | site_not_found |
| no "sites" key, or malformed JSON | 400 | Invalid request body |
Scenario: Retrying is safe
Given setup succeeded
When the same POST is sent again
Then the status is 200 and zero articles are assigned
And no row, article or story changes
And a request that asks for a different tag on an already confirmed site changes nothing for that site
And the response lists that site key in "skipped" instead of dropping the choice silently
Scenario: Failure leaves nothing half done
Given the write fails while handling the second site
When an owner posts every pending site
Then the status is 500
And no site has a confirmed default and no article is assigned
And a later POST completes every site
Scenario: Another setup is running
Given another owner's POST holds the setup
When an owner posts
Then the status is 409 and the body is "setup_in_progress"
And nothing is applied
When the first setup has finished and the owner posts again
Then the status is 200 and the finished sites are skipped
Scenario: A lock error anywhere in the setup is a conflict
Given a site row, a read, a write or the commit of the setup fails on a database lock
Then the status is 409 and the body is "setup_in_progress" and nothing is applied
Scenario: A site that became pending after the first read is locked too
Given a site is created after the setup read the pending sites and before it locked them
Then the setup locks it as well before deciding, so it must be listed or the status is 422 "setup_incomplete"
And no pending site is ever confirmed without its lock
Scenario: Two simultaneous setups on SQLite
Given a database on SQLite
When two owners post the same body at the same time
Then each answers 200 or 409 and never 500, at least one answers 200
And every article is assigned exactly once
Scenario: Setup is recorded in the activity log
When an owner completes the setup for several sites
Then one "site.language_setup" event of the sites category is recorded per confirmed site after the commit, with the default_language change and the assigned count
And a refused setup records one failed event with a plain reason, and skipped sites record nothing
Scenario: Create racing setup never strands an article
Given journal has unassigned articles
When setup commits while articles are being created concurrently in journal
Then every article of journal ends with the confirmed default tag
And none is left unassigned and none has another tag
Scenario: Locks are taken in a fixed order
Given two owners post the same pending sites in different row orders
Then neither request deadlocks and both finish with 200 or one with 409 "setup_in_progress"
Scenario: Persistence portability of the setup transaction
Then one targeted task test:mysql and task test:postgres run covers the setup transaction and its lock order
Scenario: Sites created after the migration stay pending
Given a site is created after slice 1 and has no language row
Then it is listed as pending by GET /v1/language/setup
And seeding a suggested row on site create is out of scope and tracked as a follow-up
Scenario: Route and access
Given the route /settings/sites/:key/languages/setup
Then it is open to owners only (settings.manage_sites)
And the breadcrumb and back link go to the Languages page of :key
And it is reached from the "Set up languages" banner of a site with needs_setup true
Scenario Outline: Setup states
When <trigger>
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| trigger | state | id | visible |
| the owner opens setup with Journal, Notes and Archive pending | Ready to assign | loc.l2.ready | heading "Set article languages", subtitle "Existing articles don't have a language yet. Confirm or change the language for each site.", the info alert "This labels articles. It doesn't translate them." / "Each existing article becomes the only version of its own article group. Each site's language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm."; rows "Journal 8 articles · Site locale en" with "English · en", "Notes 3 articles · No site locale" with "Choose a language" and the error "Required before you can apply.", "Archive 5 articles · Site locale pt-BR" with "Portuguese (Brazil) · pt-BR"; helper "Seeded from site locale en. Confirm or change.". The button "Confirm and assign to 16 articles" is disabled until every row has a language; the note "Counts are articles in 3 sites." |
| only Notes is pending | No-locale site | loc.l2.noLocale | the error alert "Choose a language for Notes" / "Notes has no site locale, so there is nothing to start from. Choose the language its articles are written in." and the confirm button disabled |
| only Gallery is pending (0 articles, en-GB) | Site with no articles | loc.l2.empty | the page "Languages" / "Content languages for Gallery." with the card "Confirm the default language", "Gallery has no articles yet. New articles will be written in this language.", the field "Default content language" "English (United Kingdom) · en-GB" with "Seeded from site locale en-GB. The full locale is kept. Confirm or change it.", and a "Save" button |
| the owner confirms and the POST is in flight | Applying | loc.l2.applying | the info alert "Assigning languages" / "Articles created while this runs get the site's default language." and the busy button "Assigning…" disabled |
| the POST returns 409 setup_in_progress | Setup already running | loc.l2.inProgress | the info alert "Setup is already running" / "Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site's default language. Starting it again is safe: sites that already have a language are skipped." with a "Check again" button that repeats GET /v1/language/setup; the rows are read-only and the confirm button disabled |
| the POST returns 200 with 16 articles assigned | Done | loc.l2.done | the success alert "Languages assigned" / "16 articles now have a language. Nothing was translated, and no articles were grouped." with an "Open Articles" button; the confirm button is gone |
| the POST fails with a server error | Failed | loc.l2.fail | the error alert "Nothing was changed" / "The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe." with a "Try again" button; the rows keep their values |
Scenario: The page sends one request for all rows
When the owner confirms with Journal "en", Notes "en", Archive "pt-BR"
Then exactly one POST /v1/language/setup is sent with all three rows
And the Languages page of every listed site no longer shows the setup banner
Scenario: Nothing is pending
Given the owner opens the setup page and no site is pending, including after "Check again" resolves
Then the Done success alert "Languages assigned" is shown without an article count
And this state has no frame, so the assumption is recorded in the QA notes
Scenario: Unknown or already complete :key
Given the page is opened with a :key that is unknown or whose default is already confirmed
Then the page still lists the globally pending sites
And the back link and breadcrumb go to the Languages page of :key, or to Settings, Sites when the :key is unknown
And this case has no frame, so the assumption is recorded in the QA notes
Scenario: Substituted seed
Given a site's suggestion has "substituted" true (Locale "fr-BE" gives "fr")
Then the row shows the seeded tag "fr"
And its help text reuses the Languages "broader" wording, the closest drawn copy
And this state has no frame, so the assumption is recorded in the QA notes
+51
View File
@@ -0,0 +1,51 @@
# QA: First-time language setup
Accepted together with features/language_setup.feature.
## Automated, no server (task test)
1. Use case over in-memory fakes: "Apply for every pending site", "The owner may change a suggestion", "Retrying is safe", "A site with no articles still needs confirming", refused rows. Assert sentinels with errors.Is. Assert that the whole apply is one Transactor call and that site-row locks are taken in ascending site id order.
2. HTTP: NewHandler over the use case, a fresh chi.NewRouter mirroring api/router.go under /v1/language, behind require(role.KeySettingsManageSites), validator.InitValidator(), httptest. Cover 200, 400, 401, 403, 404, 409 and 422 with exact plain-text bodies and the JSON key set. setup_in_progress is simulated by a fake whose apply returns the sentinel.
3. Regression: slice 1 tests ("Create racing a confirmation", "Assigning a language is idempotent") untouched.
## Automated, SQLite file (task test)
4. Fixture: the four sites above with 8, 3, 5 and 0 articles, one soft-deleted. POST applies; assert ids, titles, bodies, slugs, is_online, story ids and timestamps unchanged; soft-deleted articles are assigned too; no pending site remains; a second POST changes nothing (hash of all rows). A site created after migration with no row is listed and can be confirmed.
5. Atomicity: inject a failing write on the second site; assert no row is confirmed and no article is assigned; the next POST completes.
6. Concurrency: goroutines creating articles during setup; every final lang equals the confirmed tag; bound the SQLITE_BUSY retries. Two concurrent POSTs: one 200, the other 200 or 409 setup_in_progress, never a deadlock.
## Automated, real databases (task test:mysql, task test:postgres)
7. One targeted run of steps 4 to 6 on MySQL 8.4, MariaDB 11 and PostgreSQL, sharing the run planned in the site_languages QA. Check lock ordering for deadlocks. Record NOT RUN if the daemon is down, or use the podman shim.
## Automated (Vitest + Vue Test Utils, task test:web)
8. One spec per row of "Setup states". Assert the copy, the disabled confirm button until every row has a language, that Journal and Archive are seeded and Notes is not, the single POST body, and the 409 / 500 transitions.
9. Mock set: GET with Journal, Notes, Archive (8, 3, 5 articles); GET with only Notes; GET with only Gallery; GET with nothing pending; GET with a substituted suggestion; POST 200 { assigned: 16 }; POST 409; POST 500.
10. Route guard: a reader is redirected; an owner reaches the page; the back link goes to /settings/sites/:key/languages, and to Settings, Sites for an unknown :key.
11. The Languages page banner links to /settings/sites/:key/languages/setup when needs_setup is true.
12. task test:web, the type check, bun lint, and a diff check for comments.
## Visual (task visual:check -- localization-site-languages, hard gate at 10 %)
13. Run once at the end of the slice (group L2, 7 states) with the board fixtures Journal, Notes, Archive and Gallery (SETUP in locale-fixtures.js: 8, 3 and 5 articles). Desktop 1440 and mobile 390, light and dark.
14. Not checked visually (no frame): nothing-pending, unknown :key, substituted seed.
## Assumptions recorded for gaps without a frame
- Nothing pending: the Done alert without a count.
- Substituted seed: the Languages "broader" wording.
- Unknown or complete :key: the global list is shown and the back link falls back to Settings, Sites.
- Setup route: one global page (Option 1), reached from a per-site banner.
## Fix round additions
- Skipped list and stable lock loop: TestLanguageSetupSkippedChoices, TestLanguageSetupLocksUntilStable (test/localization_fix_http_test.go).
- Setup activity events: TestLanguageActivity.
- Two simultaneous setups on SQLite: TestLanguageConcurrencyOnSQLite.
## Manual
- Keyboard order and focus return after "Try again" and "Check again".
- Real two-owner run: start setup in two browsers and confirm the second sees "Setup is already running".
- A copy of a real database with articles: run setup, check the articles list and one public read still match, then restart and confirm nothing changes.
@@ -0,0 +1,42 @@
Feature: The visual checker covers the Localization board
web/visual discovers the Localization board, its groups, its 1024 px
captures and its fixtures, so that this slice and every later localization
slice is gated against docs/design.
Background:
Given the board is docs/design/source/RenewCMS Screens Localization.dc.html with frame LocaleFrame
And the state tables are docs/design/screens/localization-*.md and docs/design/states.md
And task visual:check is a hard gate: every capture within 10 %, no unmocked /v1 call, no size mismatch, no exception without the user's approval
And no threshold is changed
And nothing in docs/design is edited
Scenario: Groups are discovered from the screen file
When task visual:check runs for "localization-site-languages"
Then the checker reads the visual reference line and the three groups L1, L1b and L2
And it lists their 25, 9 and 7 states by their loc.* ids
And a state with no setup in web/visual fails the run
Scenario Outline: Capture sizes
Given a state lists <sizes> in its Sizes column
Then the checker captures the board frame and the SPA at <widths>, in light and dark
And a state with 1440 and 390 only gets no 1024 capture
Examples:
| sizes | widths |
| 1440, 390 | 1440x900 and 390x844 |
| 1440, 1024, 390 | 1440x900, 1024x? and 390x844 |
Scenario: Selecting frames
Given LocaleFrame is selected by LocaleFrame{s:{v:"sl",x:"multi"}}
Then the checker renders that state in the board and captures its frame
And a state is addressed by its loc.* id, not by the legacy name
Scenario: Fixtures and mocks
Then every state's setup uses the matching fixture of locale-fixtures.js (Journal, Atelier, Archive, Notes, Gallery)
And the mocked /v1 responses are derived from those fixtures
And the board's it, ja and zh-Hans tags are used by mocks only
And states that depend on the current date or time are skipped as clock-sensitive and nothing else is
Scenario: Existing screens are unaffected
When task visual:check runs for any screen that existed before
Then its states, sizes and verdicts are the same as before
@@ -0,0 +1,19 @@
# QA: Localization board in the visual checker
Accepted together with features/localization_visual_checker.feature.
## Automated (task test:web)
1. Unit specs next to web/visual (or its existing test location) for: group discovery from localization-site-languages.md (25, 9, 7 states and the ids), parsing of the Sizes column (1440, 1024, 390), mapping an id to its selector and to the fixture, and the failure on a state with no setup.
2. Regression: the existing screen and state lists parse the same as before (snapshot of the current discovery).
## Visual
3. Run task visual:check -- localization-site-languages once at the end of the slice. Every capture of the 41 states, at every listed size, in light and dark, must be within 10 %. The 1024 captures are for loc.l1.multi and loc.l1.social. Clock-sensitive states: none expected.
4. Run one existing screen (for example task visual:check -- profile) and confirm the same pass as before.
5. No threshold increase and no exception without the user's approval.
## Manual
- Open the Localization board in a browser and compare the LocaleFrame selectors used with the checker log.
- Check that the first run on a machine still needs bunx playwright install chromium in web/.
+301
View File
@@ -0,0 +1,301 @@
Feature: Site languages (API and Languages page)
An owner manages the content languages of a site: which are enabled, which
is the default, and the social locale of each. Every signed-in user can read
the list, only an owner can change it. The whole set is replaced atomically.
Nothing here deletes, unpublishes or relabels an article.
Background:
Given the language rules of features/article_localization_storage.feature apply
And the domain package internal/domain/language is not modified by this slice
And error responses are plain text whose body is the snake_case code
And the new rules use the design statuses 409 and 422, existing site errors keep their statuses
And an "owner" is a verified user holding settings.manage_sites and a "reader" is a verified user without it
And the design in docs/design/localization.md (L1) and screens/localization-site-languages.md is the visual spec of record
And version counts are site-level aggregates visible to every signed-in reader, an accepted exception recorded in docs/
And site "journal" (id 1, Locale "en") has a confirmed default and these languages
| lang | default | enabled | social_locale | versions | published |
| en | true | true | null | 7 | 6 |
| fr | false | true | fr_FR | 4 | 2 |
| de | false | true | null | 4 | 3 |
| ar | false | true | null | 1 | 0 |
| es | false | false | null | 1 | 0 |
| it | false | false | null | 0 | 0 |
Scenario: Read the languages
When a reader sends GET /v1/site/journal/languages
Then the status is 200
And the body has "revision" (an opaque digest string), "default" "en", "needs_setup" false
And "languages" lists en first, then the other enabled languages by tag, then the disabled ones by tag
And each language has lang, enabled, is_default, version_count, published_count, social_locale and social_locale_state
And "available" lists each selectable tag with a "substitute" that is null or the broader supported tag
And a version count covers every non-deleted article of that language, drafts and offline ones included
Scenario Outline: Read access
When <caller> sends GET /v1/site/<key>/languages
Then the status is <status> and the body is "<body>"
Examples:
| caller | key | status | body |
| an anonymous client | journal | 401 | Unauthorized |
| a reader | journal | 200 | |
| an owner | journal | 200 | |
| an owner | nowhere | 404 | site_not_found |
Scenario Outline: Social locale states
Given site "atelier" has the languages en, en-GB, fr, pt-BR with social_locale null, null, "fr_FR", null
When an owner reads its languages
Then language "<lang>" has social_locale_state "<state>" and social_locale "<explicit>"
And its effective value is "<effective>"
Examples:
| lang | state | explicit | effective |
| en-GB | derived | null | en_GB |
| pt-BR | derived | null | pt_BR |
| fr | set | fr_FR | fr_FR |
| en | not_set | null | null |
Scenario: Only the explicit value and the tag's own region resolve the social locale
Given site "atelier" has Locale "en-GB" and a language "en" with social_locale null
When an owner reads its languages
Then language "en" has social_locale_state "not_set"
And matching the Site.Locale language part is deferred to slice 6
Scenario: A site that still needs setup
Given site "notes" has no confirmed default language
When a reader sends GET /v1/site/notes/languages
Then "needs_setup" is true
When an owner sends PUT /v1/site/notes/languages with any body
Then the status is 409 and the body is "setup_required"
And nothing is stored
Scenario Outline: Changing the set
Given an owner holds the current "revision" of site "journal"
When the owner sends PUT /v1/site/journal/languages with that revision and <change>
Then the status is 200 and the body is the new read shape
And <effect>
And no article changes its lang, status or publication
Examples:
| change | effect |
| fr added with enabled true | French stays one row, enabled |
| pt-br added (lowercase) as enabled | a row "pt-BR" exists and "pt-br" does not |
| default "fr" | fr is default, en is no longer default and stays enabled |
| de enabled false | de is Disabled, its 4 versions and 3 online ones are untouched |
| es enabled true | es is enabled again, its version is untouched |
| it omitted | it is gone |
| fr social_locale "fr_CA" | fr has social_locale_state "set" and value "fr_CA" |
| fr social_locale null | fr has social_locale_state "not_set" |
Scenario Outline: Rules that reject a change
Given an owner holds the current "revision" of site "journal"
When the owner sends PUT /v1/site/journal/languages with <body>
Then the status is <status> and the body is "<code>"
And the stored languages, default and flags are unchanged
Examples:
| body | status | code |
| default "en" with en enabled false | 422 | default_not_enabled |
| default "ja" | 422 | default_not_enabled |
| every language enabled false | 422 | no_enabled_language |
| languages [] | 422 | no_enabled_language |
| es omitted (1 version) | 409 | language_in_use |
| ar omitted (1 draft or offline version) | 409 | language_in_use |
| en omitted | 422 | default_not_enabled |
| a language "english" | 422 | invalid_language |
| a language "xx" | 422 | unsupported_language |
| a language "pt-PT" | 422 | unsupported_language |
| the same tag twice, "fr" and "FR" | 422 | duplicate_language |
| fr social_locale "fr-FR" | 422 | invalid_social_locale |
| fr social_locale "fr" | 422 | invalid_social_locale |
| fr social_locale "" | 422 | invalid_social_locale |
| fr social_locale "pt_BR" | 422 | invalid_social_locale |
| no "revision" key | 400 | Invalid request body |
| malformed JSON | 400 | Invalid request body |
Scenario: Social locale format and language part
Given the format is "ll_RR" or "lll_RR", letters only and one underscore
And the language part must equal the language part of the row's tag
Then "fr_FR" and "fr_CA" are valid on "fr", and "pt_BR" is invalid on "fr"
Scenario: One bad entry rolls back the whole request
Given an owner holds the current "revision" of site "journal"
When the owner sends a PUT that makes fr the default, disables de and omits es
Then the status is 409 and the body is "language_in_use"
And fr is not the default, de is still enabled and every row is unchanged
Scenario: A stale revision is refused
Given an owner read revision R1 and another owner then changed the languages
When the first owner sends PUT with revision R1
Then the status is 409 and the body is "languages_changed"
And nothing is applied
When the first owner reads again and repeats the change with the new revision
Then the status is 200
Scenario: Sending the same state again is a no-op
Given an owner sends a valid PUT and receives revision R2
When the same body is sent again with revision R2
Then the status is 200 and the revision is still R2
And no row has changed
Scenario Outline: Only an owner can write
When <caller> sends PUT /v1/site/journal/languages with a valid body
Then the status is <status> and nothing is stored
Examples:
| caller | status |
| an anonymous client | 401 |
| a reader | 403 |
Scenario: A soft-deleted version keeps its language in use
Given language "it" of site "journal" has no live version and one soft-deleted version
When an owner sends PUT /v1/site/journal/languages that omits "it"
Then the status is 409 and the body is "language_in_use"
And the version count shown for "it" stays 0
Scenario: A busy site lock is a conflict, never a server error
Given another change holds the lock of site "journal"
When an owner sends PUT /v1/site/journal/languages with the current revision
Then the status is 409 and the body is "languages_changed"
And nothing is applied
And every lock error of the PUT (site lock, reads, writes, commit) is answered the same way on SQLite, MySQL and PostgreSQL
Scenario: Concurrent create and PUT on SQLite
Given a database on SQLite
When a PUT of the languages races the creation of another site
Then the PUT answers 200 or 409 and never 500
And the site keeps exactly one default language
Scenario: Changing the languages is recorded in the activity log
When an owner changes the set, the default or a social locale
Then one "site.languages_edit" event of the sites category targets the site once the change is committed
And its changes list the default_language when it changed and the languages before and after
When the PUT changes nothing or is a read, no event is recorded
When a PUT is refused, one failed event with a plain reason is recorded and an unknown site key records nothing
Scenario: Removing a language is decided under the site lock
Given an offline article is being created in site "journal" while an owner makes "fr" the default
Then every created article ends with the default that was current when it took the site lock
And none is left with lang ""
And a language used by a version at the time of a remove request is never removed
Scenario: Persistence additions are safe to migrate
Given a database migrated by slice 1 with site_languages rows
When database.Migrate runs
Then site_languages gains a nullable social_locale column of at most 35 characters, null on every row
And sites gains a nullable public_url_pattern column
And running Migrate again changes nothing
Scenario: Persistence portability of this slice
Given the setup transaction, the PUT atomicity and the new columns
Then one targeted task test:mysql and task test:postgres run covers them on MySQL 8.4, MariaDB 11 and PostgreSQL
Scenario: Sites created after the migration have no language row
Given a site is created after slice 1
Then it has no site_languages row and appears as pending setup
And seeding a suggested row on site create is out of scope and tracked as a follow-up
Scenario: Existing behaviour is unchanged
Then the existing site, article and slice 1 tests pass without edits
And no article read changes shape
Scenario: Route and access
Given the route /settings/sites/:key/languages
Then it is open to every verified signed-in user
And owners see the controls, other users see the Read-only view
And an unknown :key shows the Load failed state
Scenario: Entry points from Settings, Sites
Given an owner on Settings, Sites
Then each site row has the actions "Languages" and "Public URL"
And they open /settings/sites/:key/languages and /settings/sites/:key/public-url
And non-owners reach those pages by URL only
And the frame for these row actions is pending the designer, so only their presence and targets are asserted
Scenario Outline: Page states
When <trigger>
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| trigger | state | id | visible |
| the GET is in flight | Loading | loc.l1.loading | heading "Languages", subtitle "Content languages for Journal. Each article version is written in one language.", 4 skeleton rows |
| the GET fails (network or 5xx) | Load failed | loc.l1.error | alert "Couldn't load languages" / "Check your connection and try again." and a "Try again" button that repeats the GET |
| an owner opens Archive (one language pt-BR) | Single language | loc.l1.single | the card "Archive uses one language", "New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.", Default tag, "pt-BR", "Social locale" "Derived: pt_BR", the alert "Regional language kept: pt-BR", and the "Add language" button |
| an owner opens Journal | Multilingual | loc.l1.multi | the table headed "Language"; one row per language with name, code, a Default tag or a Disabled tag, "4 versions · 2 published", "Social locale" value, an "Edit" button (label "Edit social locale for French"), a "Re-enable" button on disabled rows, and an actions button (label "Actions for French"); the footnote "Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out." |
| a reader opens Journal | Read-only view | loc.l1.readonly | the alert "Only owners can change languages" / "You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.", the same rows and no Add language, Edit, Re-enable or actions button |
| an owner opens Atelier | Social locale | loc.l1.social | "Derived: en_GB" and "Derived: pt_BR", "Set: fr_FR" and "Not set" shown in the rows (1440, 1024, 390) |
Scenario Outline: Add a language
Given an owner on Journal clicks "Add language"
Then the dialog "Add a language" opens with "Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries."
When <action>
Then the screen is "<state>" (<id>) and <result>
Examples:
| action | state | id | result |
| the dialog is open with Portuguese (Brazil) chosen | Add a language | loc.l1.add | read-only "Language code" "pt-BR" ("Used in the API and in page language metadata.") and "Social locale" "Derived: pt_BR" are shown; buttons "Cancel" and "Add language" |
| the owner confirms | Saved | loc.l1.saved | a PUT with the full state plus the new row is sent, the dialog closes, and the toast "French added" / "Writers can now create French versions." appears |
| the PUT fails with a server error | Add failed | loc.l1.saveFail | the dialog stays open, the selection is kept, the alert "Couldn't save languages" / "Nothing changed. Your selection is kept. Try again." appears and the button reads "Try again" |
| French is chosen and it is already enabled | Duplicate language | loc.l1.adddup | the field error "French is already enabled for Journal." shows, "Add language" is disabled and no request is sent |
| Portuguese (Portugal) is chosen (substitute "pt") | Only a broader tag supported | loc.l1.broader | the warning "Portuguese (Portugal) isn't available" / "Only the broader language Portuguese (pt) is supported. Pages and the API will say pt, not pt-PT. Articles written in this language can't be told apart from other Portuguese.", a checked box "Add Portuguese (pt) instead", and the buttons "Cancel" and "Add Portuguese"; "pt" is sent only once that box is checked |
Scenario: Adding a language that is already listed but disabled
Given Spanish is listed on Journal as disabled
When the owner opens the Add a language dialog
Then Spanish is listed as "Disabled — re-enable instead" and cannot be added
And this state has no frame, so the copy is the assumption recorded in the QA notes
Scenario: The row menu offers three items
Given an owner on Journal opens "Actions for French"
Then the menu offers "Make default", "Disable" and "Remove"
And "Re-enable" is a separate button on disabled rows, not a menu item
And the menu itself is not drawn, so the items are inferred from the frames and recorded in the QA notes
Scenario Outline: Row actions
Given an owner on Journal
When <action>
Then the screen is "<state>" (<id>) and <result>
Examples:
| action | state | id | result |
| the French row menu "Make default" is chosen | Make default | loc.l1.default | the dialog "Make French the default language?" with "New articles start in the default language, and it is the first choice for each article's title in the list.", the info alert "No content changes", and the buttons "Cancel" and "Make French default" |
| the PUT of that change fails | Set default failed | loc.l1.defaultFail | the dialog shows the alert "Couldn't change the default language" / "Nothing changed. Try again." and "Try again" |
| the German row menu "Disable" is chosen | Disable with content | loc.l1.disable | the dialog "Disable German?" with "German is used by 4 versions: 1 draft and 3 published.", the warning "Nothing is deleted or unpublished" and the buttons "Cancel" and "Disable German" |
| the PUT of that change returns languages_changed | Languages changed | loc.l1.stale | the alert "Languages changed. Reload." / "Another owner changed the languages for Journal while this page was open. Reload to see the current list. Your change wasn't applied." and a "Reload" button that repeats the GET |
| "Disable" is chosen on the default language English | Disable default blocked | loc.lang.disableDefault | no request is sent; the warning "English is the default" / "Choose another default first, then disable English." |
| "Disable" is chosen on the only enabled language | Disable last blocked | loc.lang.disableLast | no request is sent; the warning "At least one language must stay enabled" / "Journal needs a language for new articles." |
| "Re-enable" is chosen on Spanish | Re-enable | loc.l1.reenable | the dialog "Re-enable Spanish?" with "Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes." and "Re-enable Spanish" |
| the owner confirms | Re-enabled | loc.l1.reenableDone | the toast "Spanish re-enabled" / "Writers can create Spanish versions again. Drafts can be published." |
| the PUT of that change fails | Re-enable failed | loc.l1.reenableFail | the dialog alert "Couldn't re-enable Spanish" / "Nothing changed. Spanish is still disabled. Try again." and "Try again" |
| "Remove" is chosen on Italian (0 versions) | Remove (no content) | loc.l1.remove | the dialog "Remove Italian?" / "No article uses Italian. You can add it again later." and a danger button "Remove Italian" |
| the PUT of that change fails | Remove failed | loc.l1.removeFail | the alert "Couldn't remove Italian" / "Nothing changed. Italian is still listed. Try again." and a danger "Try again" |
| "Remove" is chosen on Spanish (1 version) | Remove blocked | loc.l1.removeBlocked | no request is sent; the dialog "Spanish can't be removed" / "Spanish is used by 1 version. Disable the language to keep it, or delete it first." with "Close" and "Disable Spanish" |
Scenario: A server-side language_in_use refusal is shown as Remove blocked
Given another owner created a Spanish version after this page loaded
When the owner confirms "Remove Spanish"
Then the PUT returns 409 "language_in_use"
And the screen shows the Remove blocked dialog for Spanish and the list reloads
Scenario Outline: Social locale
Given an owner on Atelier clicks "Edit" on the French row
When the owner types "<input>"
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| input | state | id | visible |
| fr_FR | Social locale: edit | loc.l1.socialEdit | the dialog "Social locale for French" / "Used for og:locale on French pages. Leave it empty to omit the tag." with the monospace field "fr_FR", the help "Format: language_REGION, for example fr_FR. Letters only, one underscore." and "Cancel" and "Save social locale" |
| fr-FR | Social locale: invalid | loc.l1.socialInvalid | the field error "Use letters and one underscore, for example fr_FR." and "Save social locale" disabled |
Scenario: Saving a social locale
When the owner saves "fr_CA", or clears the field
Then a PUT with the full state is sent with social_locale "fr_CA", or null for an empty field
And a server 422 "invalid_social_locale" shows the same field error as the invalid state
Scenario: Setup banner
Given GET /v1/site/archive/languages returned "needs_setup" true
Then the page shows a banner with an action to /settings/sites/archive/languages/setup
And the banner has no frame, so only its presence and target are asserted and its copy is pending the designer
Scenario: Failure states with no frame
Then the Languages page failure reuses the Load failed copy
And the board's it, ja and zh-Hans tags are visual mocks only, the backend never holds them
+61
View File
@@ -0,0 +1,61 @@
# QA: Site languages (API and Languages page)
Accepted together with features/site_languages.feature. Backend: Go tests in test/, black-box. SPA: Vitest plus the visual gate.
## Automated, no server (task test)
1. Use cases over in-memory fakes of the language, site and article repositories (unique type names, shared package). Table-driven over "Rules that reject a change", "Social locale format and language part", "Changing the set", "A stale revision" and "Sending the same state again". Assert the Err sentinels with errors.Is. Assert that the update, the count of versions per language and the revision comparison run inside the Transactor under the site-row lock.
2. Revision digest: same rows give the same revision, any change of lang, default, enabled or social_locale gives another one, counts do not.
3. HTTP: NewHandler over the use cases, a fresh chi.NewRouter mirroring api/router.go (GET open to any verified user, PUT behind require(role.KeySettingsManageSites)), validator.InitValidator(), httptest, a jwt cookie signed with a test authtoken secret. Cover read, 401, 403, 404, 400, 409, 422 and 200. Assert status and exact plain-text body for every error. Assert the JSON key set of the read.
4. Regression: existing site tests (the site JSON gains public_url_pattern only in the public-url feature) and article tests untouched. The internal/domain/language package is unchanged (diff check).
## Automated, SQLite file (task test)
5. Migration: a slice 1 database gains site_languages.social_locale and sites.public_url_pattern. Existing rows are unchanged. A second Migrate changes nothing (sqlite_master and a row hash).
6. PUT atomicity: inject a failing write on the third row; assert nothing changed. Assert language_in_use against a draft, an offline and an online article, and that a soft-deleted article does not count.
7. Concurrency: N goroutines create articles while PUT changes the default. Assert every article has the default current at its lock and none has lang "". Retry SQLITE_BUSY and bound the retries.
## Automated, real databases (task test:mysql, task test:postgres)
8. One targeted run of steps 5 to 7 (columns, PUT atomicity, version count) on MySQL 8.4, MariaDB 11 and PostgreSQL. The setup transaction is covered by the language_setup QA. Record NOT RUN if the daemon is down, or use the podman shim.
## Automated (Vitest + Vue Test Utils, task test:web)
Mock /v1 per case. Do not freeze the system date.
9. One spec per row of "Page states", "Add a language", "Row actions" and "Social locale". Assert the exact copy, the accessible names ("Edit social locale for French", "Actions for French"), the request body of each PUT (full state, the revision from the last GET, social_locale null for an empty field) and that blocked states send no request.
10. Mock set: GET 200 for Journal, Atelier and Archive (counts from the board fixtures), GET 5xx, PUT 200, PUT 500, PUT 409 languages_changed, PUT 409 language_in_use, PUT 422 invalid_social_locale.
11. Route guard: a reader reaches the page and sees no controls; an anonymous visitor is redirected to login.
12. SitesView: each row has "Languages" and "Public URL" actions with the right targets, for owners.
13. Add dialog: a disabled language is listed "Disabled — re-enable instead" and cannot be added. Row menu items are Make default, Disable, Remove; Re-enable is a row button.
14. i18n en/fr parity for every new key.
15. task test:web, the type check, bun lint, and a diff check for comments.
## Visual (task visual:check -- localization-site-languages, hard gate at 10 %)
16. Run once at the end of the slice (group L1, 25 states). Desktop 1440 and mobile 390, light and dark. 1024 for loc.l1.multi and loc.l1.social. Reuse the board fixtures: Journal (4 enabled, es and it disabled), Atelier (social locale), Archive (single), counts from locale-fixtures.js. The board's it, ja and zh-Hans are mocks only.
17. Known risk: the mobile rows are taller than needed (localization-corrections.md); tune in implementation, never raise the threshold.
18. Not checked visually (no frame): setup banner, row menu, disabled language in the picker, SitesView row actions.
## Assumptions recorded for gaps without a frame
- Setup banner: presence and target only, copy pending the designer.
- Row menu: Make default, Disable, Remove.
- Disabled language in the picker: "Disabled — re-enable instead".
- 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.
## Fix round additions
- Soft-deleted version keeps the language in use: test/localization_fix_http_test.go (TestLanguageInUseCountsSoftDeletedVersions) and the sqlite persistence case.
- Lock contention to 409: TestLanguageLockContention, TestDriverLockContention, TestTransactorClassifiesLockContention.
- SQLite concurrency: TestLanguageConcurrencyOnSQLite (docs/api/site-languages.md explains why FOR UPDATE is a no-op there).
- Activity: TestLanguageActivity.
## Manual
- Keyboard order and focus return in dialogs and the row menu; Escape closes; Tab order on mobile (44 px targets).
- Dialog motion, hover and reduced-motion.
- Two owners editing at once, in two browsers: the second sees "Languages changed. Reload."
- Intl.DisplayNames names (for example "Portuguese (Brazil)") in en and fr.
- docs/ gains the HTTP contract (shapes and codes above) and the accepted visibility exception for the next SPA slices.
- task mutate scoped with MUTATE_FILES to the new use cases.
+121
View File
@@ -0,0 +1,121 @@
Feature: Public URL pattern of a site
An owner can give each site one pattern for the address of its articles on
the website, with {lang} and {slug}. It is validated on the server. When it
is unset the product shows "Public URL not configured" and invents no URL.
Background:
Given error responses are plain text whose body is the snake_case code
And an "owner" is a verified user holding settings.manage_sites and a "reader" is a verified user without it
And the pattern is stored on the site as public_url_pattern, a string or null, at most 512 characters
And it is read through the existing GET /v1/site/ list (every verified user) and written through the existing PATCH /v1/site/{id} (owner)
And no new route is added
And the design in docs/design/localization.md (L1b) is the visual spec of record
Scenario: Reading the pattern
Given site 1 has public_url_pattern "https://journal.example.com/{lang}/{slug}"
When a reader sends GET /v1/site/
Then the item for site 1 contains "public_url_pattern" with that value
And every key present before this change is present with an unchanged value and type
Scenario Outline: Writing the pattern
When an owner sends PATCH /v1/site/1 with {"public_url_pattern": <value>}
Then the status is <status> and <effect>
Examples:
| value | status | effect |
| "https://journal.example.com/{lang}/{slug}" | 200 | the site returns that value |
| "https://journal.example.com/{lang}/articles/{slug}" | 200 | the site returns that value |
| "http://localhost:3000/{slug}" | 200 | the site returns that value |
| "https://journal.example.com/{slug}/{slug}" | 200 | the site returns that value (repeats allowed) |
| null | 200 | the pattern is unset |
| "" | 200 | the pattern is unset |
| " https://journal.example.com/{slug} " | 200 | the stored value is trimmed |
Scenario Outline: Patterns that are refused
When an owner sends PATCH /v1/site/1 with {"public_url_pattern": "<value>"}
Then the status is 422 and the body is "<code>"
And the stored pattern is unchanged
Examples:
| value | code |
| ftp://journal.example.com/{lang}/{slug} | invalid_scheme |
| journal.example.com/{slug} | invalid_scheme |
| https:///{slug} | invalid_host |
| https://journal.example.com/{language}/{slug} | unknown_placeholder |
| https://journal.example.com/{lang | unknown_placeholder |
| https://journal.example.com/my pages/{lang}/{slug} | not_encoded |
| https://journal.example.com/é/{slug} | not_encoded |
| https://journal.example.com/{lang} | missing_slug |
| a pattern of 513 characters | invalid_pattern |
Scenario: A pattern is checked in a fixed order
When the pattern is "ftp://x.example.com/{language} here"
Then the code is "invalid_scheme"
And with scheme fixed the code is "unknown_placeholder", then "not_encoded", then "missing_slug"
Scenario Outline: Only an owner can write
When <caller> sends PATCH /v1/site/1 with a valid pattern
Then the status is <status> and the stored pattern is unchanged
Examples:
| caller | status |
| an anonymous client | 401 |
| a reader | 403 |
Scenario: Saving the pattern changes nothing else
When an owner sets the pattern
Then no article, canonical URL or public response changes
And the site's other fields are unchanged
Scenario: Existing site behaviour is unchanged
Then the existing site tests pass without edits and a PATCH without public_url_pattern leaves it unchanged
Scenario: Route and access
Given the route /settings/sites/:key/public-url
Then it is open to every verified signed-in user
And owners can edit, other users see the Read-only view
And owners reach it from the "Public URL" action on the Settings, Sites row, non-owners by URL only
Scenario Outline: Public URL states
When <trigger>
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| trigger | state | id | visible |
| an owner opens Journal with the pattern saved | Set | loc.l1b.set | heading "Public URL", subtitle "Where Journal articles appear on your website. Used for canonical and alternate links.", the monospace field "Public URL pattern" holding https://journal.example.com/{lang}/{slug}, the help "Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.", the preview line and "Save" and "Cancel" |
| the owner types https://journal.example.com/{lang}/articles/{slug} | Editing | loc.l1b.editing | the preview follows the field on each change |
| the owner types a pattern with {language} | Unknown placeholder | loc.l1b.unknown | the error "Unknown placeholder {language}. Use {lang} and {slug}." and Save disabled |
| the owner types ftp://journal.example.com/{lang}/{slug} | Bad scheme | loc.l1b.scheme | the error "Start with https:// or http://." and Save disabled |
| the owner types a pattern with a space in the path | Not encoded | loc.l1b.encoded | the error "Remove spaces and characters that need encoding. Use letters, digits, - and _." and Save disabled |
| the owner types https://journal.example.com/{lang} | Missing {slug} | loc.l1b.noSlug | the error "Add {slug} so each article has its own address." and Save disabled |
| the owner saves and the PATCH returns 200 | Saved | loc.l1b.saved | a success toast; the field holds the saved value |
| an owner opens a site with no pattern from "Public URL not configured" | Unset | loc.l1b.unset | the info alert "Public URL not configured" / "Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.", the placeholder https://example.com/{lang}/{slug}, no preview and Save disabled |
| a reader opens Journal | Read-only view | loc.l1b.viewOnly | the alert "Only owners can change this" / "You can read the pattern. Ask an owner to change it.", the field read-only and no Save or Cancel |
Scenario: Client and server agree
Given the page validates as the owner types
When the server still returns 422 with unknown_placeholder, invalid_scheme, not_encoded or missing_slug
Then the field shows the same error as the matching state above
Scenario: Preview
Given the preview uses a real article of the site, shown with the site's default language
Then {lang} is replaced by the tag and {slug} by the article slug
Scenario: Preview of a site without articles
Given the site has no articles
Then the preview line is not shown
And this state has no frame, so the assumption is recorded in the QA notes
Scenario: Cancel
When the owner edits the field and presses "Cancel"
Then the field returns to the saved value and nothing is sent
And the design does not describe Cancel, so the assumption is recorded in the QA notes
Scenario: Clearing the field
When the owner empties the field and saves
Then a PATCH with null is sent and the site shows the Unset state
Scenario: Failures with no frame
When the load or the save fails with a 5xx
Then the page reuses the Languages "Couldn't load languages" / "Couldn't save languages" alert copy
And these states are skipped by the visual gate
+38
View File
@@ -0,0 +1,38 @@
# QA: Public URL pattern
Accepted together with features/site_public_url_pattern.feature.
## Automated, no server (task test)
1. Domain: table-driven over "Patterns that are refused" and "Writing the pattern" and the check order. Assert the new Err sentinels with errors.Is.
2. HTTP: the existing site handler over use cases with in-memory fakes, a fresh chi.NewRouter mirroring NewSiteRouter, validator.InitValidator(), httptest, jwt cookie. Cover GET /v1/site/ (key set, new field) and PATCH /v1/site/{id}: 200, 401, 403, 422 with exact plain-text bodies, and a PATCH without the field. Assert no new route exists.
3. Regression: the existing site tests untouched.
## Automated, SQLite file (task test)
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)
5. One spec per row of "Public URL states". Assert exact copy, that Save is disabled for each invalid state, the live preview, the PATCH body (value or null) sent to /v1/site/{id}, and the mapping of server 422 codes to the same messages.
6. Mock set: GET /v1/site/ with Journal set, Journal unset, GET /v1/article/ with one article (title "Comment nous chiffrons une rénovation de salle de bain", lang fr), PATCH 200, PATCH 422.
7. Route guard: a reader sees the Read-only view; an anonymous visitor is redirected.
8. Cancel resets to the saved value without a request; a site with no articles shows no preview.
9. i18n en/fr parity, task test:web, the type check, bun lint, a diff check for comments.
## Visual (task visual:check -- localization-site-languages, hard gate at 10 %)
10. Run once at the end of the slice (group L1b, 9 states). Desktop 1440 and mobile 390, light and dark. Board fixture Journal. The preview article is the board's French sample: it comes from the mocked article list.
11. Skipped (no frame): save failure, load failure, no-articles preview.
## Assumptions recorded for gaps without a frame
- Cancel resets the field to the saved value.
- No articles: no preview line.
- Save and load failures reuse the Languages alert copy.
- The design's GET/PUT /v1/site/{key} is replaced by the existing list and PATCH /v1/site/{id}.
## Manual
- Keyboard and focus order, error announcement, reduced motion.
- A real owner edit on a dev server: save a pattern, reload, clear it.
@@ -106,4 +106,5 @@ type Repository interface {
ClearImage(imageID uint32) (ImageReferences, error) ClearImage(imageID uint32) (ImageReferences, error)
RestoreImage(references ImageReferences, imageID uint32) error RestoreImage(references ImageReferences, imageID uint32) error
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
AssignUnassignedLanguage(siteID uint32, lang string) (int, error)
} }
+6
View File
@@ -78,6 +78,8 @@ const (
ActionSiteEdit = "site.edit" ActionSiteEdit = "site.edit"
ActionSiteSetDefault = "site.set_default" ActionSiteSetDefault = "site.set_default"
ActionSiteDelete = "site.delete" ActionSiteDelete = "site.delete"
ActionSiteLanguagesEdit = "site.languages_edit"
ActionSiteLanguageSetup = "site.language_setup"
ActionAuthorCreate = "author.create" ActionAuthorCreate = "author.create"
ActionAuthorEdit = "author.edit" ActionAuthorEdit = "author.edit"
ActionAuthorDelete = "author.delete" ActionAuthorDelete = "author.delete"
@@ -208,6 +210,10 @@ const (
FieldSiteName = "site_name" FieldSiteName = "site_name"
FieldTitleTemplate = "title_template" FieldTitleTemplate = "title_template"
FieldDefaultDescription = "default_description" FieldDefaultDescription = "default_description"
FieldPublicURLPattern = "public_url_pattern"
FieldLanguages = "languages"
FieldDefaultLanguage = "default_language"
ParamAssigned = "assigned"
maxNameLength = 255 maxNameLength = 255
maxActionLength = 64 maxActionLength = 64
maxFailureReasonSize = 255 maxFailureReasonSize = 255
@@ -0,0 +1,47 @@
package localization
import "errors"
var (
ErrSetupRequired = errors.New("the site has no confirmed default language")
ErrLanguagesChanged = errors.New("the languages changed since they were read")
ErrLanguageInUse = errors.New("a language still used by a version cannot be removed")
ErrDefaultNotEnabled = errors.New("the default language must be one of the enabled languages")
ErrNoEnabledLanguage = errors.New("at least one language must stay enabled")
ErrDuplicateLanguage = errors.New("the same language is listed twice")
ErrInvalidSocialLocale = errors.New("invalid social locale")
ErrSetupInProgress = errors.New("another language setup is running")
ErrSetupIncomplete = errors.New("every pending site needs a language")
ErrDuplicateSite = errors.New("the same site is listed twice")
)
const (
SocialLocaleSet = "set"
SocialLocaleDerived = "derived"
SocialLocaleNotSet = "not_set"
)
type Row struct {
Tag string
IsDefault bool
Enabled bool
Confirmed bool
SocialLocale *string
}
type VersionCount struct {
Total int
Published int
Trashed int
}
type Entry struct {
Tag string
Enabled bool
SocialLocale *string
}
type Change struct {
Default string
Languages []Entry
}
@@ -0,0 +1,9 @@
package localization
type Repository interface {
Rows(siteID uint32) ([]Row, error)
ConfirmedSites() (map[uint32]bool, error)
VersionCounts(siteID uint32) (map[string]VersionCount, error)
Replace(siteID uint32, rows []Row) error
ConfirmSetup(siteID uint32, tag string) error
}
@@ -0,0 +1,41 @@
package localization
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strconv"
"strings"
)
func Revision(rows []Row) string {
ordered := slices.Clone(rows)
slices.SortFunc(ordered, func(left Row, right Row) int { return strings.Compare(left.Tag, right.Tag) })
digest := sha256.New()
for _, row := range ordered {
digest.Write([]byte(row.Tag + "|" + strconv.FormatBool(row.IsDefault) + "|" + strconv.FormatBool(row.Enabled) + "|" + socialText(row.SocialLocale) + "\n"))
}
return hex.EncodeToString(digest.Sum(nil))
}
func SameRows(left []Row, right []Row) bool {
return Revision(left) == Revision(right)
}
func (r Row) Equal(other Row) bool {
return r.Tag == other.Tag && r.IsDefault == other.IsDefault && r.Enabled == other.Enabled && r.Confirmed == other.Confirmed && sameSocialLocale(r.SocialLocale, other.SocialLocale)
}
func sameSocialLocale(left *string, right *string) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func socialText(social *string) string {
if social == nil {
return ""
}
return *social
}
@@ -0,0 +1,95 @@
package localization
import (
"RenewCMS/internal/domain/language"
"regexp"
"slices"
"strings"
)
var socialLocalePattern = regexp.MustCompile(`^([A-Za-z]{2,3})_([A-Za-z]{2})$`)
func NormalizeSocialLocale(tag string, social *string) (*string, error) {
if social == nil {
return nil, nil
}
parts := socialLocalePattern.FindStringSubmatch(*social)
if parts == nil {
return nil, ErrInvalidSocialLocale
}
tagLanguage, _, _ := strings.Cut(tag, "-")
if !strings.EqualFold(parts[1], tagLanguage) {
return nil, ErrInvalidSocialLocale
}
normalized := strings.ToLower(parts[1]) + "_" + strings.ToUpper(parts[2])
return &normalized, nil
}
func SocialLocaleState(row Row) string {
if row.SocialLocale != nil {
return SocialLocaleSet
}
if _, region, found := strings.Cut(row.Tag, "-"); found && region != "" {
return SocialLocaleDerived
}
return SocialLocaleNotSet
}
func EffectiveSocialLocale(row Row) *string {
switch SocialLocaleState(row) {
case SocialLocaleSet:
return row.SocialLocale
case SocialLocaleDerived:
derived := strings.Replace(row.Tag, "-", "_", 1)
return &derived
}
return nil
}
func normalizeEntries(entries []Entry) ([]Entry, error) {
normalized := make([]Entry, 0, len(entries))
seen := map[string]bool{}
for _, entry := range entries {
tag, err := language.Normalize(entry.Tag)
if err != nil {
return nil, err
}
if seen[tag] {
return nil, ErrDuplicateLanguage
}
seen[tag] = true
social, err := NormalizeSocialLocale(tag, entry.SocialLocale)
if err != nil {
return nil, err
}
normalized = append(normalized, Entry{Tag: tag, Enabled: entry.Enabled, SocialLocale: social})
}
return normalized, nil
}
func Plan(current []Row, counts map[string]VersionCount, change Change) ([]Row, error) {
entries, err := normalizeEntries(change.Languages)
if err != nil {
return nil, err
}
if !slices.ContainsFunc(entries, func(entry Entry) bool { return entry.Enabled }) {
return nil, ErrNoEnabledLanguage
}
defaultTag, err := language.Normalize(change.Default)
if err != nil || !slices.ContainsFunc(entries, func(entry Entry) bool { return entry.Tag == defaultTag && entry.Enabled }) {
return nil, ErrDefaultNotEnabled
}
confirmed := map[string]bool{}
for _, row := range current {
confirmed[row.Tag] = row.Confirmed
if !slices.ContainsFunc(entries, func(entry Entry) bool { return entry.Tag == row.Tag }) && counts[row.Tag].Total+counts[row.Tag].Trashed > 0 {
return nil, ErrLanguageInUse
}
}
next := make([]Row, 0, len(entries))
for _, entry := range entries {
isDefault := entry.Tag == defaultTag
next = append(next, Row{Tag: entry.Tag, IsDefault: isDefault, Enabled: entry.Enabled, Confirmed: confirmed[entry.Tag] || isDefault, SocialLocale: entry.SocialLocale})
}
return next, nil
}
@@ -0,0 +1,49 @@
package localization
import (
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/site"
)
type SetupSuggestion struct {
Lang string `json:"lang"`
Substituted bool `json:"substituted"`
}
type PendingSite struct {
Key string `json:"key"`
Name string `json:"name"`
Locale *string `json:"locale"`
ArticleCount int `json:"article_count"`
Suggestion *SetupSuggestion `json:"suggestion"`
}
type SetupView struct {
Sites []PendingSite `json:"sites"`
Available []Option `json:"available"`
}
type SetupChoice struct {
Key string
Lang string
}
type SetupAssignment struct {
Key string `json:"key"`
Lang string `json:"lang"`
Assigned int `json:"assigned"`
}
type SetupResult struct {
Assigned int `json:"assigned"`
Sites []SetupAssignment `json:"sites"`
Skipped []string `json:"skipped"`
}
func NewPendingSite(s site.Site) PendingSite {
pending := PendingSite{Key: s.Key, Name: s.Name, Locale: s.Locale, ArticleCount: s.ArticleCount}
if suggestion := language.Suggest(s.Locale); suggestion.Found {
pending.Suggestion = &SetupSuggestion{Lang: suggestion.Tag, Substituted: suggestion.Substituted}
}
return pending
}
@@ -0,0 +1,91 @@
package localization
import (
"RenewCMS/internal/domain/language"
"slices"
"strings"
)
type LanguageView struct {
Lang string `json:"lang"`
Enabled bool `json:"enabled"`
IsDefault bool `json:"is_default"`
VersionCount int `json:"version_count"`
PublishedCount int `json:"published_count"`
SocialLocale *string `json:"social_locale"`
SocialLocaleState string `json:"social_locale_state"`
}
type Option struct {
Lang string `json:"lang"`
Substitute *string `json:"substitute"`
}
type View struct {
Revision string `json:"revision"`
Default *string `json:"default"`
NeedsSetup bool `json:"needs_setup"`
Languages []LanguageView `json:"languages"`
Available []Option `json:"available"`
}
var unsupportedVariants = []string{"de-AT", "de-DE", "es-ES", "es-MX", "fr-BE", "fr-FR", "pt-PT"}
func NeedsSetup(rows []Row) bool {
return !slices.ContainsFunc(rows, func(row Row) bool { return row.IsDefault && row.Confirmed })
}
func Available() []Option {
options := []Option{}
for _, tag := range language.Supported() {
options = append(options, Option{Lang: tag})
}
for _, tag := range unsupportedVariants {
base, _, _ := strings.Cut(tag, "-")
substitute := base
options = append(options, Option{Lang: tag, Substitute: &substitute})
}
slices.SortFunc(options, func(left Option, right Option) int { return strings.Compare(left.Lang, right.Lang) })
return options
}
func BuildView(rows []Row, counts map[string]VersionCount) View {
view := View{Revision: Revision(rows), NeedsSetup: NeedsSetup(rows), Languages: []LanguageView{}, Available: Available()}
if view.NeedsSetup {
return view
}
ordered := slices.Clone(rows)
slices.SortFunc(ordered, compareRows)
for _, row := range ordered {
if row.IsDefault {
tag := row.Tag
view.Default = &tag
}
view.Languages = append(view.Languages, LanguageView{
Lang: row.Tag,
Enabled: row.Enabled,
IsDefault: row.IsDefault,
VersionCount: counts[row.Tag].Total,
PublishedCount: counts[row.Tag].Published,
SocialLocale: row.SocialLocale,
SocialLocaleState: SocialLocaleState(row),
})
}
return view
}
func compareRows(left Row, right Row) int {
if left.IsDefault != right.IsDefault {
if left.IsDefault {
return -1
}
return 1
}
if left.Enabled != right.Enabled {
if left.Enabled {
return -1
}
return 1
}
return strings.Compare(left.Tag, right.Tag)
}
+9
View File
@@ -20,6 +20,13 @@ var (
ErrInvalidReassign = errors.New("invalid reassign destination") ErrInvalidReassign = errors.New("invalid reassign destination")
ErrInvalidTitleTemplate = errors.New("the title template must contain %title%") ErrInvalidTitleTemplate = errors.New("the title template must contain %title%")
ErrInvalidPublicURLPattern = errors.New("invalid public url pattern")
ErrPublicURLScheme = errors.New("the public url pattern must start with https:// or http://")
ErrPublicURLHost = errors.New("the public url pattern must have a host")
ErrPublicURLPlaceholder = errors.New("the public url pattern has an unknown placeholder")
ErrPublicURLNotEncoded = errors.New("the public url pattern has characters that need encoding")
ErrPublicURLMissingSlug = errors.New("the public url pattern must contain {slug}")
) )
const ( const (
@@ -60,6 +67,7 @@ type Site struct {
SiteName string `json:"site_name"` SiteName string `json:"site_name"`
TitleTemplate string `json:"title_template"` TitleTemplate string `json:"title_template"`
DefaultDescription string `json:"default_description"` DefaultDescription string `json:"default_description"`
PublicURLPattern *string `json:"public_url_pattern"`
} }
type Nullable[T any] struct { type Nullable[T any] struct {
@@ -78,6 +86,7 @@ type Patch struct {
SiteName *string SiteName *string
TitleTemplate *string TitleTemplate *string
DefaultDescription *string DefaultDescription *string
PublicURLPattern Nullable[string]
} }
func IsValidKey(key string) bool { func IsValidKey(key string) bool {
@@ -0,0 +1,117 @@
package site
import (
"net/url"
"strings"
"unicode/utf8"
)
const (
MaxPublicURLPatternRunes = 512
LangPlaceholder = "{lang}"
SlugPlaceholder = "{slug}"
placeholderFiller = "x"
allowedURLSymbols = "-._~:/?#[]@!$&'()*+,;=%"
)
func NormalizePublicURLPattern(pattern *string) (*string, error) {
if pattern == nil {
return nil, nil
}
trimmed := strings.TrimSpace(*pattern)
if trimmed == "" {
return nil, nil
}
if utf8.RuneCountInString(trimmed) > MaxPublicURLPatternRunes {
return nil, ErrInvalidPublicURLPattern
}
if err := checkPublicURLScheme(trimmed); err != nil {
return nil, err
}
if err := checkPlaceholders(trimmed); err != nil {
return nil, err
}
filled := fillPlaceholders(trimmed)
if err := checkEncoding(filled); err != nil {
return nil, err
}
if !strings.Contains(trimmed, SlugPlaceholder) {
return nil, ErrPublicURLMissingSlug
}
if err := checkPublicURLHost(filled); err != nil {
return nil, err
}
return &trimmed, nil
}
func checkPublicURLScheme(pattern string) error {
lowered := strings.ToLower(pattern)
if !strings.HasPrefix(lowered, "https://") && !strings.HasPrefix(lowered, "http://") {
return ErrPublicURLScheme
}
return nil
}
func checkPlaceholders(pattern string) error {
remaining := pattern
for {
open := strings.IndexAny(remaining, "{}")
if open < 0 {
return nil
}
if remaining[open] == '}' {
return ErrPublicURLPlaceholder
}
rest := remaining[open:]
switch {
case strings.HasPrefix(rest, LangPlaceholder):
remaining = rest[len(LangPlaceholder):]
case strings.HasPrefix(rest, SlugPlaceholder):
remaining = rest[len(SlugPlaceholder):]
default:
return ErrPublicURLPlaceholder
}
}
}
func fillPlaceholders(pattern string) string {
return strings.NewReplacer(LangPlaceholder, placeholderFiller, SlugPlaceholder, placeholderFiller).Replace(pattern)
}
func checkEncoding(filled string) error {
for index := 0; index < len(filled); index++ {
character := filled[index]
if character == '%' {
if index+2 >= len(filled) || !isHex(filled[index+1]) || !isHex(filled[index+2]) {
return ErrPublicURLNotEncoded
}
index += 2
continue
}
if !isAllowedURLByte(character) {
return ErrPublicURLNotEncoded
}
}
return nil
}
func isHex(character byte) bool {
return character >= '0' && character <= '9' || character >= 'a' && character <= 'f' || character >= 'A' && character <= 'F'
}
func isAllowedURLByte(character byte) bool {
if character >= '0' && character <= '9' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' {
return true
}
return strings.IndexByte(allowedURLSymbols, character) >= 0
}
func checkPublicURLHost(filled string) error {
parsed, err := url.Parse(filled)
if err != nil || parsed.Hostname() == "" {
return ErrPublicURLHost
}
return nil
}
@@ -20,6 +20,8 @@ type Repository interface {
Create(site Site) (Site, error) Create(site Site) (Site, error)
Update(site Site) (Site, error) Update(site Site) (Site, error)
SetDefault(id uint32) (Site, error) SetDefault(id uint32) (Site, error)
LockSite(id uint32) error
TryLockSites(ids []uint32) error
DeleteMovingArticles(id uint32, reassignTo *uint32) error DeleteMovingArticles(id uint32, reassignTo *uint32) error
ClearImage(imageID uint32) ([]uint32, error) ClearImage(imageID uint32) ([]uint32, error)
AssignImage(siteIDs []uint32, imageID uint32) error AssignImage(siteIDs []uint32, imageID uint32) error
@@ -6,10 +6,14 @@ import (
"RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/language" "RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"context" "context"
"errors"
) )
var ErrLockContention = errors.New("the database could not lock the rows in time")
type Repositories struct { type Repositories struct {
Articles article.Repository Articles article.Repository
Sites site.Repository Sites site.Repository
@@ -17,6 +21,7 @@ type Repositories struct {
Entries entry.Repository Entries entry.Repository
Images image.Repository Images image.Repository
Languages language.Repository Languages language.Repository
Localization localization.Repository
} }
type Transactor interface { type Transactor interface {
@@ -23,6 +23,7 @@ type Config struct {
type Driver interface { type Driver interface {
Open(source string) (*gorm.DB, error) Open(source string) (*gorm.DB, error)
NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup
IsLockContention(err error) bool
} }
var drivers = map[string]Driver{ var drivers = map[string]Driver{
@@ -31,6 +32,15 @@ var drivers = map[string]Driver{
DriverPostgres: postgresDriver{}, DriverPostgres: postgresDriver{},
} }
func IsLockContentionFor(driverName string, err error) bool {
driver, ok := drivers[driverName]
return ok && err != nil && driver.IsLockContention(err)
}
func IsLockContention(db *gorm.DB, err error) bool {
return IsLockContentionFor(db.Name(), err)
}
type Connection struct { type Connection struct {
DB *gorm.DB DB *gorm.DB
driver Driver driver Driver
@@ -2,6 +2,7 @@ package database
import ( import (
domainUpdate "RenewCMS/internal/domain/update" domainUpdate "RenewCMS/internal/domain/update"
"errors"
"fmt" "fmt"
"net" "net"
"strings" "strings"
@@ -22,6 +23,8 @@ const (
mysqlSessionSQLMode = "'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'" mysqlSessionSQLMode = "'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"
mysqlTableOptionsKey = "gorm:table_options" mysqlTableOptionsKey = "gorm:table_options"
redactedPassword = "***" redactedPassword = "***"
mysqlLockNowait = 3572
mysqlLockWaitTimeout = 1205
) )
type mysqlDriver struct{} type mysqlDriver struct{}
@@ -84,3 +87,8 @@ func tableOptionsFor(db *gorm.DB) *gorm.DB {
} }
return db return db
} }
func (mysqlDriver) IsLockContention(err error) bool {
var failure *mysqldriver.MySQLError
return errors.As(err, &failure) && (failure.Number == mysqlLockNowait || failure.Number == mysqlLockWaitTimeout)
}
@@ -2,6 +2,7 @@ package database
import ( import (
domainUpdate "RenewCMS/internal/domain/update" domainUpdate "RenewCMS/internal/domain/update"
"errors"
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
@@ -21,6 +22,7 @@ const (
postgresScheme = "postgres" postgresScheme = "postgres"
postgresUTCCallback = "renewcms:postgres_utc" postgresUTCCallback = "renewcms:postgres_utc"
gormQueryCallback = "gorm:query" gormQueryCallback = "gorm:query"
postgresLockNotAvailable = "55P03"
) )
var timeType = reflect.TypeOf(time.Time{}) var timeType = reflect.TypeOf(time.Time{})
@@ -106,3 +108,8 @@ func convertStructTimesToUTC(value reflect.Value) {
func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup { func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewPostgresBackup(db, directory, now) return NewPostgresBackup(db, directory, now)
} }
func (postgresDriver) IsLockContention(err error) bool {
var failure *pgconn.PgError
return errors.As(err, &failure) && failure.Code == postgresLockNotAvailable
}
@@ -2,6 +2,7 @@ package database
import ( import (
domainUpdate "RenewCMS/internal/domain/update" domainUpdate "RenewCMS/internal/domain/update"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -13,7 +14,12 @@ import (
const DriverSQLite = "sqlite" const DriverSQLite = "sqlite"
const sqliteBusyTimeoutMs = 5000 const (
sqliteBusyTimeoutMs = 5000
sqliteResultBusy = 5
sqliteResultLocked = 6
sqlitePrimaryMask = 0xff
)
type sqliteDriver struct{} type sqliteDriver struct{}
@@ -31,3 +37,12 @@ func (sqliteDriver) Open(file string) (*gorm.DB, error) {
func (sqliteDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup { func (sqliteDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewSQLiteBackup(db, directory, now) return NewSQLiteBackup(db, directory, now)
} }
func (sqliteDriver) IsLockContention(err error) bool {
var coded interface{ Code() int }
if !errors.As(err, &coded) {
return false
}
primary := coded.Code() & sqlitePrimaryMask
return primary == sqliteResultBusy || primary == sqliteResultLocked
}
@@ -0,0 +1,27 @@
package mappers
import (
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/infrastructure/persistence/models"
)
func LocalizationRowToDomain(m models.SiteLanguage) localization.Row {
return localization.Row{
Tag: m.Lang,
IsDefault: m.IsDefault,
Enabled: m.Enabled,
Confirmed: m.Confirmed,
SocialLocale: m.SocialLocale,
}
}
func LocalizationRowToModel(siteID uint32, row localization.Row) models.SiteLanguage {
return models.SiteLanguage{
SiteID: siteID,
Lang: row.Tag,
IsDefault: row.IsDefault,
Enabled: row.Enabled,
Confirmed: row.Confirmed,
SocialLocale: row.SocialLocale,
}
}
@@ -23,6 +23,7 @@ func SiteToDomain(m models.Site, articleCount int) site.Site {
SiteName: m.SiteName, SiteName: m.SiteName,
TitleTemplate: titleTemplate, TitleTemplate: titleTemplate,
DefaultDescription: m.DefaultDescription, DefaultDescription: m.DefaultDescription,
PublicURLPattern: m.PublicURLPattern,
} }
} }
@@ -39,5 +40,6 @@ func SiteToModel(s site.Site) models.Site {
SiteName: s.SiteName, SiteName: s.SiteName,
TitleTemplate: s.TitleTemplate, TitleTemplate: s.TitleTemplate,
DefaultDescription: s.DefaultDescription, DefaultDescription: s.DefaultDescription,
PublicURLPattern: s.PublicURLPattern,
} }
} }
@@ -8,6 +8,7 @@ type SiteLanguage struct {
IsDefault bool `gorm:"not null;default:false"` IsDefault bool `gorm:"not null;default:false"`
Enabled bool `gorm:"not null;default:true"` Enabled bool `gorm:"not null;default:true"`
Confirmed bool `gorm:"not null;default:false"` Confirmed bool `gorm:"not null;default:false"`
SocialLocale *string `gorm:"size:35"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
} }
@@ -14,6 +14,7 @@ type Site struct {
SiteName string `gorm:"size:100;not null;default:''"` SiteName string `gorm:"size:100;not null;default:''"`
TitleTemplate string `gorm:"size:200"` TitleTemplate string `gorm:"size:200"`
DefaultDescription string `gorm:"size:320;not null;default:''"` DefaultDescription string `gorm:"size:320;not null;default:''"`
PublicURLPattern *string `gorm:"size:512"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
} }
@@ -3,6 +3,7 @@ package persistence
import ( import (
domain "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author" authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"errors" "errors"
@@ -605,6 +606,11 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
var _ domain.Repository = &ArticleRepository{} var _ domain.Repository = &ArticleRepository{}
func (a *ArticleRepository) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) {
result := a.db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", siteID, language.Unassigned).UpdateColumn("lang", lang)
return int(result.RowsAffected), result.Error
}
func (a *ArticleRepository) ListForAccount(creatorID uint32, authorID *uint32) ([]domain.Article, error) { func (a *ArticleRepository) ListForAccount(creatorID uint32, authorID *uint32) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{}) query := a.db.Model(&entity.Article{})
if authorID == nil { if authorID == nil {
@@ -0,0 +1,149 @@
package persistence
import (
domain "RenewCMS/internal/domain/localization"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"slices"
"gorm.io/gorm"
)
type LocalizationRepository struct {
db *gorm.DB
}
func NewLocalizationRepository(db *gorm.DB) *LocalizationRepository {
return &LocalizationRepository{db}
}
type languageTotal struct {
Lang string
Total int
}
func (l *LocalizationRepository) Rows(siteID uint32) ([]domain.Row, error) {
var found []entity.SiteLanguage
if err := l.db.Where("site_id = ?", siteID).Order("lang ASC").Find(&found).Error; err != nil {
return nil, err
}
rows := make([]domain.Row, 0, len(found))
for _, row := range found {
rows = append(rows, mappers.LocalizationRowToDomain(row))
}
return rows, nil
}
func (l *LocalizationRepository) ConfirmedSites() (map[uint32]bool, error) {
var ids []uint32
err := l.db.Model(&entity.SiteLanguage{}).Where("is_default = ? AND confirmed = ?", true, true).Distinct().Pluck("site_id", &ids).Error
if err != nil {
return nil, err
}
confirmed := make(map[uint32]bool, len(ids))
for _, id := range ids {
confirmed[id] = true
}
return confirmed, nil
}
func (l *LocalizationRepository) VersionCounts(siteID uint32) (map[string]domain.VersionCount, error) {
totals, err := l.countByLanguage(l.db.Where("site_id = ?", siteID))
if err != nil {
return nil, err
}
published, err := l.countByLanguage(l.db.Where("site_id = ? AND is_online = ?", siteID, true))
if err != nil {
return nil, err
}
trashed, err := l.countByLanguage(l.db.Unscoped().Where("site_id = ? AND deleted_at IS NOT NULL", siteID))
if err != nil {
return nil, err
}
counts := make(map[string]domain.VersionCount, len(totals))
for lang, total := range totals {
counts[lang] = domain.VersionCount{Total: total, Published: published[lang]}
}
for lang, count := range trashed {
kept := counts[lang]
kept.Trashed = count
counts[lang] = kept
}
return counts, nil
}
func (l *LocalizationRepository) countByLanguage(scope *gorm.DB) (map[string]int, error) {
var totals []languageTotal
err := scope.Model(&entity.Article{}).Select("lang, COUNT(*) AS total").Group("lang").Scan(&totals).Error
if err != nil {
return nil, err
}
counted := make(map[string]int, len(totals))
for _, total := range totals {
counted[total.Lang] = total.Total
}
return counted, nil
}
func (l *LocalizationRepository) Replace(siteID uint32, rows []domain.Row) error {
existing, err := l.Rows(siteID)
if err != nil {
return err
}
known := make(map[string]domain.Row, len(existing))
for _, row := range existing {
known[row.Tag] = row
}
wanted := make([]string, 0, len(rows))
for _, row := range rows {
wanted = append(wanted, row.Tag)
}
for _, row := range existing {
if !slices.Contains(wanted, row.Tag) {
if err := l.db.Where("site_id = ? AND lang = ?", siteID, row.Tag).Delete(&entity.SiteLanguage{}).Error; err != nil {
return err
}
}
}
if err := l.clearOtherDefaults(siteID, rows); err != nil {
return err
}
for _, row := range rows {
if err := l.save(siteID, row, known); err != nil {
return err
}
}
return nil
}
func (l *LocalizationRepository) clearOtherDefaults(siteID uint32, rows []domain.Row) error {
for _, row := range rows {
if row.IsDefault {
return l.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND is_default = ? AND lang <> ?", siteID, true, row.Tag).UpdateColumn("is_default", false).Error
}
}
return nil
}
func (l *LocalizationRepository) save(siteID uint32, row domain.Row, known map[string]domain.Row) error {
before, exists := known[row.Tag]
if !exists {
model := mappers.LocalizationRowToModel(siteID, row)
return l.db.Create(&model).Error
}
if before.Equal(row) {
return nil
}
changes := map[string]any{"is_default": row.IsDefault, "enabled": row.Enabled, "confirmed": row.Confirmed, "social_locale": row.SocialLocale}
return l.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", siteID, row.Tag).Updates(changes).Error
}
func (l *LocalizationRepository) ConfirmSetup(siteID uint32, tag string) error {
if err := l.db.Where("site_id = ? AND confirmed = ?", siteID, false).Delete(&entity.SiteLanguage{}).Error; err != nil {
return err
}
confirmed := entity.SiteLanguage{SiteID: siteID, Lang: tag, IsDefault: true, Enabled: true, Confirmed: true}
return l.db.Create(&confirmed).Error
}
var _ domain.Repository = &LocalizationRepository{}
@@ -4,6 +4,7 @@ import (
domain "RenewCMS/internal/domain/site" domain "RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"slices"
"strings" "strings"
"gorm.io/gorm" "gorm.io/gorm"
@@ -166,6 +167,24 @@ func lockSite(tx *gorm.DB, id uint32) (entity.Site, error) {
return locked, nil return locked, nil
} }
func (s *SiteRepository) LockSite(id uint32) error {
_, err := lockSite(s.db, id)
return err
}
func (s *SiteRepository) TryLockSites(ids []uint32) error {
ordered := slices.Clone(ids)
slices.Sort(ordered)
for _, id := range ordered {
var locked entity.Site
err := s.db.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).First(&locked, id).Error
if err != nil {
return mapNotFoundErr(err, domain.ErrSiteNotFound)
}
}
return nil
}
func unflagDefaults(tx *gorm.DB, exceptID uint32) error { func unflagDefaults(tx *gorm.DB, exceptID uint32) error {
return tx.Model(&entity.Site{}).Where("is_default = ? AND id <> ?", true, exceptID).UpdateColumn("is_default", false).Error return tx.Model(&entity.Site{}).Where("is_default = ? AND id <> ?", true, exceptID).UpdateColumn("is_default", false).Error
} }
@@ -180,6 +199,7 @@ func (s *SiteRepository) Update(site domain.Site) (domain.Site, error) {
"site_name": site.SiteName, "site_name": site.SiteName,
"title_template": site.TitleTemplate, "title_template": site.TitleTemplate,
"default_description": site.DefaultDescription, "default_description": site.DefaultDescription,
"public_url_pattern": site.PublicURLPattern,
} }
if _, err := s.Get(site.ID); err != nil { if _, err := s.Get(site.ID); err != nil {
return domain.Site{}, err return domain.Site{}, err
@@ -2,7 +2,9 @@ package persistence
import ( import (
domain "RenewCMS/internal/domain/transaction" domain "RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/persistence/database"
"context" "context"
"fmt"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -18,7 +20,7 @@ func NewTransactor(db *gorm.DB) *Transactor {
} }
func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error { func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error {
return t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return work(domain.Repositories{ return work(domain.Repositories{
Articles: NewArticleRepository(tx), Articles: NewArticleRepository(tx),
Sites: NewSiteRepository(tx), Sites: NewSiteRepository(tx),
@@ -26,6 +28,11 @@ func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) err
Entries: NewEntryRepository(tx), Entries: NewEntryRepository(tx),
Images: NewImageRepository(tx), Images: NewImageRepository(tx),
Languages: NewSiteLanguageRepository(tx), Languages: NewSiteLanguageRepository(tx),
Localization: NewLocalizationRepository(tx),
}) })
}) })
if database.IsLockContention(t.db, err) {
return fmt.Errorf("%w: %w", domain.ErrLockContention, err)
}
return err
} }
@@ -0,0 +1,162 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
"fmt"
"slices"
)
type ApplyLanguageSetupUseCase struct {
transactor transaction.Transactor
recorder audit.Recorder
}
func NewApplyLanguageSetupUseCase(transactor transaction.Transactor, recorder audit.Recorder) *ApplyLanguageSetupUseCase {
return &ApplyLanguageSetupUseCase{transactor, recorder}
}
func (a *ApplyLanguageSetupUseCase) Apply(ctx context.Context, choices []localization.SetupChoice) (localization.SetupResult, error) {
event := audit.NewEvent(audit.ActionSiteLanguageSetup, audit.CategorySites)
result, confirmed, err := a.confirm(ctx, choices)
if errors.Is(err, transaction.ErrLockContention) {
err = fmt.Errorf("%w: %w", localization.ErrSetupInProgress, err)
}
if err != nil {
recordFailure(ctx, a.recorder, event, err)
return localization.SetupResult{}, err
}
for _, found := range confirmed {
a.recordConfirmed(ctx, found, result)
}
return result, nil
}
func (a *ApplyLanguageSetupUseCase) confirm(ctx context.Context, choices []localization.SetupChoice) (localization.SetupResult, []site.Site, error) {
chosen, err := normalizeSetupChoices(choices)
if err != nil {
return localization.SetupResult{}, nil, err
}
var result localization.SetupResult
var confirmed []site.Site
err = a.transactor.Run(ctx, func(tx transaction.Repositories) error {
result = localization.SetupResult{Sites: []localization.SetupAssignment{}, Skipped: []string{}}
confirmed = nil
for key := range chosen {
if _, err := tx.Sites.GetByKey(key); err != nil {
return err
}
}
pending, err := lockPendingSites(tx)
if err != nil {
return err
}
for _, found := range pending {
if _, ok := chosen[found.Key]; !ok {
return localization.ErrSetupIncomplete
}
}
for _, found := range pending {
assignment, err := confirmSite(tx, found, chosen[found.Key])
if err != nil {
return err
}
result.Assigned += assignment.Assigned
result.Sites = append(result.Sites, assignment)
confirmed = append(confirmed, found)
}
result.Skipped = skippedChoices(chosen, pending)
return nil
})
if err != nil {
return localization.SetupResult{}, nil, err
}
return result, confirmed, nil
}
func (a *ApplyLanguageSetupUseCase) recordConfirmed(ctx context.Context, found site.Site, result localization.SetupResult) {
event := audit.NewEvent(audit.ActionSiteLanguageSetup, audit.CategorySites)
event.Target = siteTarget(found)
for _, assignment := range result.Sites {
if assignment.Key == found.Key {
event.Changes = []audit.Change{audit.TextChange(audit.FieldDefaultLanguage, "", assignment.Lang)}
event.Params[audit.ParamAssigned] = assignment.Assigned
}
}
a.recorder.Record(ctx, event)
}
func lockPendingSites(tx transaction.Repositories) ([]site.Site, error) {
locked := map[uint32]bool{}
for {
pending, err := pendingSites(tx.Sites, tx.Localization)
if err != nil {
return nil, err
}
missing := []uint32{}
for _, id := range siteIDs(pending) {
if !locked[id] {
missing = append(missing, id)
}
}
if len(missing) == 0 {
return pending, nil
}
if err := tx.Sites.TryLockSites(missing); err != nil {
return nil, err
}
for _, id := range missing {
locked[id] = true
}
}
}
func confirmSite(tx transaction.Repositories, found site.Site, tag string) (localization.SetupAssignment, error) {
if err := tx.Localization.ConfirmSetup(found.ID, tag); err != nil {
return localization.SetupAssignment{}, err
}
assigned, err := tx.Articles.AssignUnassignedLanguage(found.ID, tag)
if err != nil {
return localization.SetupAssignment{}, err
}
return localization.SetupAssignment{Key: found.Key, Lang: tag, Assigned: assigned}, nil
}
func skippedChoices(chosen map[string]string, pending []site.Site) []string {
skipped := []string{}
for key := range chosen {
if !slices.ContainsFunc(pending, func(found site.Site) bool { return found.Key == key }) {
skipped = append(skipped, key)
}
}
slices.Sort(skipped)
return skipped
}
func normalizeSetupChoices(choices []localization.SetupChoice) (map[string]string, error) {
chosen := make(map[string]string, len(choices))
for _, choice := range choices {
tag, err := language.Normalize(choice.Lang)
if err != nil {
return nil, err
}
if _, repeated := chosen[choice.Key]; repeated {
return nil, localization.ErrDuplicateSite
}
chosen[choice.Key] = tag
}
return chosen, nil
}
func siteIDs(sites []site.Site) []uint32 {
ids := make([]uint32, 0, len(sites))
for _, found := range sites {
ids = append(ids, found.ID)
}
return ids
}
@@ -0,0 +1,48 @@
package useCases
import (
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"cmp"
"slices"
)
type GetLanguageSetupUseCase struct {
sites site.Repository
localization localization.Repository
}
func NewGetLanguageSetupUseCase(sites site.Repository, localization localization.Repository) *GetLanguageSetupUseCase {
return &GetLanguageSetupUseCase{sites, localization}
}
func (g *GetLanguageSetupUseCase) Get() (localization.SetupView, error) {
pending, err := pendingSites(g.sites, g.localization)
if err != nil {
return localization.SetupView{}, err
}
view := localization.SetupView{Sites: []localization.PendingSite{}, Available: localization.Available()}
for _, found := range pending {
view.Sites = append(view.Sites, localization.NewPendingSite(found))
}
return view, nil
}
func pendingSites(sites site.Repository, repository localization.Repository) ([]site.Site, error) {
all, err := sites.List()
if err != nil {
return nil, err
}
confirmed, err := repository.ConfirmedSites()
if err != nil {
return nil, err
}
pending := []site.Site{}
for _, candidate := range all {
if !confirmed[candidate.ID] {
pending = append(pending, candidate)
}
}
slices.SortFunc(pending, func(left site.Site, right site.Site) int { return cmp.Compare(left.ID, right.ID) })
return pending, nil
}
@@ -0,0 +1,35 @@
package useCases
import (
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
)
type GetSiteLanguagesUseCase struct {
sites site.Repository
localization localization.Repository
}
func NewGetSiteLanguagesUseCase(sites site.Repository, localization localization.Repository) *GetSiteLanguagesUseCase {
return &GetSiteLanguagesUseCase{sites, localization}
}
func (g *GetSiteLanguagesUseCase) Get(key string) (localization.View, error) {
found, err := g.sites.GetByKey(key)
if err != nil {
return localization.View{}, err
}
return readSiteLanguages(g.localization, found.ID)
}
func readSiteLanguages(repository localization.Repository, siteID uint32) (localization.View, error) {
rows, err := repository.Rows(siteID)
if err != nil {
return localization.View{}, err
}
counts, err := repository.VersionCounts(siteID)
if err != nil {
return localization.View{}, err
}
return localization.BuildView(rows, counts), nil
}
@@ -92,6 +92,13 @@ func (u *UpdateSiteUseCase) apply(before site.Site, patch site.Patch) (site.Site
if patch.DefaultDescription != nil { if patch.DefaultDescription != nil {
after.DefaultDescription = *patch.DefaultDescription after.DefaultDescription = *patch.DefaultDescription
} }
if patch.PublicURLPattern.Set {
pattern, err := site.NormalizePublicURLPattern(patch.PublicURLPattern.Value)
if err != nil {
return site.Site{}, err
}
after.PublicURLPattern = pattern
}
return after, nil return after, nil
} }
@@ -107,6 +114,7 @@ func (u *UpdateSiteUseCase) changes(before site.Site, after site.Site) []audit.C
changes = audit.AppendTextChange(changes, audit.FieldSiteName, before.SiteName, after.SiteName) changes = audit.AppendTextChange(changes, audit.FieldSiteName, before.SiteName, after.SiteName)
changes = audit.AppendTextChange(changes, audit.FieldTitleTemplate, before.TitleTemplate, after.TitleTemplate) changes = audit.AppendTextChange(changes, audit.FieldTitleTemplate, before.TitleTemplate, after.TitleTemplate)
changes = audit.AppendTextChange(changes, audit.FieldDefaultDescription, before.DefaultDescription, after.DefaultDescription) changes = audit.AppendTextChange(changes, audit.FieldDefaultDescription, before.DefaultDescription, after.DefaultDescription)
changes = audit.AppendTextChange(changes, audit.FieldPublicURLPattern, optionalText(before.PublicURLPattern), optionalText(after.PublicURLPattern))
return changes return changes
} }
@@ -0,0 +1,128 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
"fmt"
"slices"
"strings"
)
type UpdateSiteLanguagesUseCase struct {
transactor transaction.Transactor
recorder audit.Recorder
}
type SiteLanguageEntry struct {
Lang string
Enabled bool
SocialLocale *string
}
type UpdateSiteLanguagesCommand struct {
Key string
Revision string
Default string
Languages []SiteLanguageEntry
}
func NewUpdateSiteLanguagesUseCase(transactor transaction.Transactor, recorder audit.Recorder) *UpdateSiteLanguagesUseCase {
return &UpdateSiteLanguagesUseCase{transactor, recorder}
}
func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateSiteLanguagesCommand) (localization.View, error) {
event := audit.NewEvent(audit.ActionSiteLanguagesEdit, audit.CategorySites)
var view localization.View
var before []localization.Row
var after []localization.Row
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
found, err := tx.Sites.GetByKey(command.Key)
if err != nil {
return err
}
event.Target = siteTarget(found)
if err := tx.Sites.LockSite(found.ID); err != nil {
return err
}
before, err = tx.Localization.Rows(found.ID)
if err != nil {
return err
}
if localization.NeedsSetup(before) {
return localization.ErrSetupRequired
}
if localization.Revision(before) != command.Revision {
return localization.ErrLanguagesChanged
}
counts, err := tx.Localization.VersionCounts(found.ID)
if err != nil {
return err
}
after, err = localization.Plan(before, counts, command.change())
if err != nil {
return err
}
if !localization.SameRows(before, after) {
if err := tx.Localization.Replace(found.ID, after); err != nil {
return err
}
}
view, err = readSiteLanguages(tx.Localization, found.ID)
return err
})
if errors.Is(err, transaction.ErrLockContention) {
err = fmt.Errorf("%w: %w", localization.ErrLanguagesChanged, err)
}
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return localization.View{}, err
}
if changes := languageChanges(before, after); len(changes) > 0 {
event.Changes = changes
u.recorder.Record(ctx, event)
}
return view, nil
}
func languageChanges(before []localization.Row, after []localization.Row) []audit.Change {
changes := []audit.Change{}
changes = audit.AppendTextChange(changes, audit.FieldDefaultLanguage, defaultTag(before), defaultTag(after))
return audit.AppendTextChange(changes, audit.FieldLanguages, languagesLabel(before), languagesLabel(after))
}
func defaultTag(rows []localization.Row) string {
for _, row := range rows {
if row.IsDefault {
return row.Tag
}
}
return ""
}
func languagesLabel(rows []localization.Row) string {
ordered := slices.Clone(rows)
slices.SortFunc(ordered, func(left localization.Row, right localization.Row) int { return strings.Compare(left.Tag, right.Tag) })
labels := make([]string, 0, len(ordered))
for _, row := range ordered {
label := row.Tag
if !row.Enabled {
label += " (off)"
}
if row.SocialLocale != nil {
label += " [" + *row.SocialLocale + "]"
}
labels = append(labels, label)
}
return strings.Join(labels, ", ")
}
func (c UpdateSiteLanguagesCommand) change() localization.Change {
entries := make([]localization.Entry, 0, len(c.Languages))
for _, entry := range c.Languages {
entries = append(entries, localization.Entry{Tag: entry.Lang, Enabled: entry.Enabled, SocialLocale: entry.SocialLocale})
}
return localization.Change{Default: c.Default, Languages: entries}
}
@@ -6,8 +6,11 @@ import (
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/purge" "RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/domain/update" "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"context" "context"
@@ -76,6 +79,18 @@ var failureReasons = []failureReason{
{site.ErrInvalidTitleTemplate, "The title template must contain %title%."}, {site.ErrInvalidTitleTemplate, "The title template must contain %title%."},
{site.ErrUseSetDefault, "Use the default site action to change the default."}, {site.ErrUseSetDefault, "Use the default site action to change the default."},
{site.ErrInvalidReassign, "The destination site is not valid."}, {site.ErrInvalidReassign, "The destination site is not valid."},
{localization.ErrSetupRequired, "The site has no confirmed default language yet."},
{localization.ErrLanguagesChanged, "The languages changed since they were read."},
{localization.ErrLanguageInUse, "A language still used by a version cannot be removed."},
{localization.ErrDefaultNotEnabled, "The default language must be an enabled language."},
{localization.ErrNoEnabledLanguage, "At least one language must stay enabled."},
{localization.ErrDuplicateLanguage, "The same language is listed twice."},
{localization.ErrInvalidSocialLocale, "The social locale is not valid."},
{localization.ErrSetupIncomplete, "Every site that needs a language must be listed."},
{localization.ErrDuplicateSite, "The same site is listed twice."},
{language.ErrInvalidLanguage, "The language tag is not valid."},
{language.ErrUnsupportedLanguage, "That language is not supported."},
{transaction.ErrLockContention, "Another change is running on this site. Please try again."},
{author.ErrSlugTaken, "That slug is already used by another author."}, {author.ErrSlugTaken, "That slug is already used by another author."},
{author.ErrUserAlreadyLinked, "That user already has an author."}, {author.ErrUserAlreadyLinked, "That user already has an author."},
{author.ErrAuthorAlreadyLinked, "That author is already linked to a user."}, {author.ErrAuthorAlreadyLinked, "That author is already linked to a user."},
@@ -45,3 +45,10 @@ func localeLabel(locale *string) string {
} }
return *locale return *locale
} }
func optionalText(text *string) string {
if text == nil {
return ""
}
return *text
}
+16
View File
@@ -3,6 +3,7 @@ package test
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors" "errors"
"slices" "slices"
@@ -475,6 +476,21 @@ func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Artic
return result, nil return result, nil
} }
func (m *mockArticleRepo) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) {
if err := m.fail.trigger("AssignUnassignedLanguage"); err != nil {
return 0, err
}
assigned := 0
for id, stored := range m.articles {
if stored.SiteID == siteID && stored.Lang == language.Unassigned {
stored.Lang = lang
m.articles[id] = stored
assigned++
}
}
return assigned, nil
}
var _ article.Repository = &mockArticleRepo{} var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) { func TestCreateArticle(t *testing.T) {
+317
View File
@@ -0,0 +1,317 @@
package test
import (
"RenewCMS/internal/domain/localization"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
const everyPendingSite = `{"sites":[{"key":"journal","lang":"en"},{"key":"notes","lang":"en"},{"key":"archive","lang":"pt-BR"},{"key":"gallery","lang":"en-GB"}]}`
type setupReadBody struct {
Sites []struct {
Key string `json:"key"`
Name string `json:"name"`
Locale *string `json:"locale"`
ArticleCount int `json:"article_count"`
Suggestion *struct {
Lang string `json:"lang"`
Substituted bool `json:"substituted"`
} `json:"suggestion"`
} `json:"sites"`
Available []struct {
Lang string `json:"lang"`
} `json:"available"`
}
type setupResultBody struct {
Assigned int `json:"assigned"`
Sites []struct {
Key string `json:"key"`
Lang string `json:"lang"`
Assigned int `json:"assigned"`
} `json:"sites"`
}
func decodeSetupResult(t *testing.T, rec *httptest.ResponseRecorder) setupResultBody {
t.Helper()
var body setupResultBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body, err)
}
return body
}
func (e *localizationEnv) postSetup(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return e.owner(t, http.MethodPost, "/v1/language/setup", body)
}
func (e *localizationEnv) unassigned() int {
total := 0
for _, stored := range e.articles.articles {
if stored.Lang == "" {
total++
}
}
return total
}
func TestLanguageSetup(t *testing.T) {
t.Run("Read what is pending", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.sites.seed(9, "Done", "done", false)
e.seedRows(9, localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true})
rec := e.owner(t, http.MethodGet, "/v1/language/setup", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var body setupReadBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if keys := keysOf(decodeFields(t, rec)); !reflect.DeepEqual(keys, []string{"available", "sites"}) {
t.Errorf("keys = %v", keys)
}
keys := []string{}
counts := map[string]int{}
suggestions := map[string]string{}
for _, listed := range body.Sites {
keys = append(keys, listed.Key)
counts[listed.Key] = listed.ArticleCount
if listed.Suggestion != nil {
suggestions[listed.Key] = listed.Suggestion.Lang
if listed.Suggestion.Substituted {
t.Errorf("%s suggestion is substituted", listed.Key)
}
}
}
if !reflect.DeepEqual(keys, []string{"journal", "notes", "archive", "gallery"}) {
t.Errorf("sites = %v", keys)
}
if counts["journal"] != 8 || counts["notes"] != 3 || counts["archive"] != 5 || counts["gallery"] != 0 {
t.Errorf("counts = %v", counts)
}
if _, ok := suggestions["notes"]; ok || suggestions["archive"] != "pt-BR" {
t.Errorf("suggestions = %v", suggestions)
}
if len(body.Available) == 0 {
t.Errorf("available is empty")
}
})
t.Run("A substituted suggestion is flagged", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(2, "Belgium", "belgium", false)
be := "fr-BE"
stored := e.sites.sites[2]
stored.Locale = &be
e.sites.sites[2] = stored
var body setupReadBody
_ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/language/setup", "").Body.Bytes(), &body)
if len(body.Sites) != 1 || body.Sites[0].Suggestion == nil || body.Sites[0].Suggestion.Lang != "fr" || !body.Sites[0].Suggestion.Substituted {
t.Errorf("body = %+v", body)
}
})
t.Run("Read access", func(t *testing.T) {
cases := []struct {
name string
user uint32
status int
}{
{"an anonymous client", 0, http.StatusUnauthorized},
{"a reader", readerUserID, http.StatusForbidden},
{"an owner", apiKeyOwnerId, http.StatusOK},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
if rec := e.as(t, c.user, http.MethodGet, "/v1/language/setup", ""); rec.Code != c.status {
t.Errorf("status %d", rec.Code)
}
})
}
})
t.Run("Apply for every pending site", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
rec := e.postSetup(t, everyPendingSite)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
result := decodeSetupResult(t, rec)
if result.Assigned != 16 || len(result.Sites) != 4 {
t.Errorf("result = %+v", result)
}
for _, id := range []uint32{journalSiteID, notesSiteID, archiveSiteID, gallerySiteID} {
if localization.NeedsSetup(e.store.rows[id]) || len(e.store.rows[id]) != 1 || !e.store.rows[id][0].Enabled {
t.Errorf("site %d rows = %+v", id, e.store.rows[id])
}
}
if e.unassigned() != 0 || len(e.articles.articles) != 16 {
t.Errorf("unassigned %d of %d", e.unassigned(), len(e.articles.articles))
}
_, fields := e.read(t, "journal")
if string(fields["needs_setup"]) != "false" {
t.Errorf("needs_setup = %s", fields["needs_setup"])
}
if e.transactor.runs != 1 {
t.Errorf("transactions = %d", e.transactor.runs)
}
})
t.Run("The owner may change a suggestion", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
rec := e.postSetup(t, `{"sites":[{"key":"journal","lang":"en"},{"key":"notes","lang":"en"},{"key":"archive","lang":"pt"},{"key":"gallery","lang":"en-GB"}]}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
rows := e.store.rows[archiveSiteID]
if len(rows) != 1 || rows[0].Tag != "pt" || !rows[0].IsDefault || !rows[0].Confirmed {
t.Errorf("rows = %+v", rows)
}
for _, stored := range e.articles.articles {
if stored.SiteID == archiveSiteID && stored.Lang != "pt" {
t.Errorf("article lang = %q", stored.Lang)
}
}
})
t.Run("A site with no articles still needs confirming", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
rec := e.postSetup(t, `{"sites":[{"key":"journal","lang":"en"},{"key":"notes","lang":"en"},{"key":"archive","lang":"pt-BR"}]}`)
expectSiteStatusAndBody(t, rec, http.StatusUnprocessableEntity, "setup_incomplete")
if e.unassigned() != 16 || !localization.NeedsSetup(e.store.rows[journalSiteID]) {
t.Errorf("something was applied")
}
})
refused := []struct {
name string
body string
status int
code string
}{
{"notes with lang english", `{"sites":[{"key":"notes","lang":"english"}]}`, 422, "invalid_language"},
{"notes with lang xx", `{"sites":[{"key":"notes","lang":"xx"}]}`, 422, "unsupported_language"},
{"notes with an empty lang", `{"sites":[{"key":"notes","lang":""}]}`, 422, "invalid_language"},
{"the same site key twice", `{"sites":[{"key":"notes","lang":"en"},{"key":"notes","lang":"fr"}]}`, 422, "duplicate_site"},
{"a site key nowhere", `{"sites":[{"key":"nowhere","lang":"en"}]}`, 404, "site_not_found"},
{"no sites key", `{}`, 400, "The request cannot be processed due to a mismatch in the format of the body."},
{"malformed JSON", `{"sites":`, 400, "The request cannot be processed due to a mismatch in the format of the body."},
}
for _, c := range refused {
t.Run("Rows that are refused/"+c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
rec := e.postSetup(t, c.body)
expectSiteStatusAndBody(t, rec, c.status, c.code)
if e.unassigned() != 16 || len(e.store.rows) != 3 {
t.Errorf("something changed")
}
})
}
t.Run("Retrying is safe", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.postSetup(t, everyPendingSite)
rowsBefore, articlesBefore := e.store.snapshot()
again := e.postSetup(t, `{"sites":[{"key":"journal","lang":"fr"},{"key":"notes","lang":"en"},{"key":"archive","lang":"pt-BR"},{"key":"gallery","lang":"en-GB"}]}`)
if again.Code != http.StatusOK || decodeSetupResult(t, again).Assigned != 0 {
t.Fatalf("again: %d %s", again.Code, again.Body)
}
if rowsAfter, articlesAfter := e.store.snapshot(); !reflect.DeepEqual(rowsBefore, rowsAfter) || !reflect.DeepEqual(articlesBefore, articlesAfter) {
t.Errorf("a retry changed something")
}
})
t.Run("Failure leaves nothing half done", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.store.failConfirmAt = 2
rec := e.postSetup(t, everyPendingSite)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if e.unassigned() != 16 || !localization.NeedsSetup(e.store.rows[journalSiteID]) {
t.Errorf("a site was half done")
}
if later := e.postSetup(t, everyPendingSite); later.Code != http.StatusOK || decodeSetupResult(t, later).Assigned != 16 {
t.Errorf("later: %d %s", later.Code, later.Body)
}
})
t.Run("Another setup is running", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.sites.busy = true
rec := e.postSetup(t, everyPendingSite)
expectSiteStatusAndBody(t, rec, http.StatusConflict, "setup_in_progress")
if e.unassigned() != 16 {
t.Errorf("something was applied")
}
e.sites.busy = false
if later := e.postSetup(t, everyPendingSite); later.Code != http.StatusOK {
t.Errorf("later: %d", later.Code)
}
})
t.Run("Locks are taken in a fixed order", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.postSetup(t, everyPendingSite)
if !reflect.DeepEqual(e.sites.tryLocked, [][]uint32{{1, 2, 3, 4}}) {
t.Errorf("locks = %v", e.sites.tryLocked)
}
if !reflect.DeepEqual(e.store.confirmedOrder, []uint32{1, 2, 3, 4}) {
t.Errorf("confirm order = %v", e.store.confirmedOrder)
}
})
t.Run("Sites created after the migration stay pending", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.sites.seed(8, "Fresh", "fresh", false)
var body setupReadBody
_ = json.Unmarshal(e.owner(t, http.MethodGet, "/v1/language/setup", "").Body.Bytes(), &body)
listed := false
for _, pending := range body.Sites {
listed = listed || pending.Key == "fresh"
}
if !listed {
t.Errorf("fresh site not listed: %+v", body.Sites)
}
})
}
+145
View File
@@ -0,0 +1,145 @@
package test
import (
languagehandler "RenewCMS/api/handlers/language"
"RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/site"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func newLanguageRouter(db *gorm.DB) http.Handler {
validator.InitValidator()
sites := persistence.NewSiteRepository(db)
reads := persistence.NewLocalizationRepository(db)
transactor := persistence.NewTransactor(db)
siteLanguages := languagehandler.NewSiteLanguagesHandler(useCases.NewGetSiteLanguagesUseCase(sites, reads), useCases.NewUpdateSiteLanguagesUseCase(transactor, discardRecorder))
setup := languagehandler.NewSetupHandler(useCases.NewGetLanguageSetupUseCase(sites, reads), useCases.NewApplyLanguageSetupUseCase(transactor, discardRecorder))
router := chi.NewRouter()
router.Put("/site/{key}/languages", siteLanguages.Update)
router.Post("/language/setup", setup.Apply)
return router
}
func sendTo(router http.Handler, method string, path string, body string) int {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body)))
return rec.Code
}
func runTogether(jobs ...func()) {
var group sync.WaitGroup
start := make(chan struct{})
for _, job := range jobs {
group.Add(1)
go func() {
defer group.Done()
<-start
job()
}()
}
close(start)
group.Wait()
}
func TestLanguageConcurrencyOnSQLite(t *testing.T) {
t.Run("PUT languages racing a site creation answers 200 or 409, never 500, and keeps one default", func(t *testing.T) {
db := openContractSQLite(t)
journal := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true}}, map[string][]entity.Article{"en": {{Title: "a", IsOnline: true}}})
router := newLanguageRouter(db)
read := useCases.NewGetSiteLanguagesUseCase(persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db))
sites := persistence.NewSiteRepository(db)
extra := []string{"fr", "de", "es", "ar", "pt-BR", "en-GB"}
for round, tag := range extra {
view, err := read.Get("journal")
if err != nil {
t.Fatal(err)
}
body := languagesBody(view.Revision, "en", entryJSON("en", true, "null"), entryJSON(tag, true, "null"))
status := 0
var createErr error
runTogether(
func() { status = sendTo(router, http.MethodPut, "/site/journal/languages", body) },
func() {
key := fmt.Sprintf("race-%d", round)
_, createErr = sites.Create(site.Site{Name: key, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: "index"})
},
)
if status != http.StatusOK && status != http.StatusConflict {
t.Errorf("round %d: PUT status %d", round, status)
}
if createErr != nil {
t.Logf("round %d: create refused: %v", round, createErr)
}
defaults := 0
for _, row := range storedLanguageRows(t, db, journal.ID) {
if row.IsDefault {
defaults++
}
}
if defaults != 1 {
t.Errorf("round %d: %d defaults", round, defaults)
}
}
})
t.Run("Two simultaneous setups answer 200 or 409, at least one 200, and assign every article exactly once", func(t *testing.T) {
for round := 0; round < 5; round++ {
db := openContractSQLite(t)
first := seedLocalizedSite(t, db, "journal", nil, map[string][]entity.Article{"": {{Title: "a"}, {Title: "deleted"}}})
second := seedLocalizedSite(t, db, "notes", nil, map[string][]entity.Article{"": {{Title: "b"}, {Title: "c"}}})
router := newLanguageRouter(db)
body := `{"sites":[{"key":"default","lang":"en"},{"key":"journal","lang":"en"},{"key":"notes","lang":"fr"}]}`
statuses := make([]int, 2)
runTogether(
func() { statuses[0] = sendTo(router, http.MethodPost, "/language/setup", body) },
func() { statuses[1] = sendTo(router, http.MethodPost, "/language/setup", body) },
)
accepted := 0
for _, status := range statuses {
switch status {
case http.StatusOK:
accepted++
case http.StatusConflict:
default:
t.Errorf("round %d: status %d", round, status)
}
}
if accepted == 0 {
t.Errorf("round %d: no setup succeeded: %v", round, statuses)
}
if accepted == 2 {
t.Logf("round %d: the setups were serialized", round)
}
if sendTo(router, http.MethodPost, "/language/setup", body) != http.StatusOK {
t.Errorf("round %d: a later retry did not succeed", round)
}
var unassigned int64
db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned)
if unassigned != 0 {
t.Errorf("round %d: %d articles unassigned", round, unassigned)
}
for siteID, tag := range map[uint32]string{first.ID: "en", second.ID: "fr"} {
rows := storedLanguageRows(t, db, siteID)
if len(rows) != 1 || rows[0].Lang != tag || !rows[0].IsDefault || !rows[0].Confirmed {
t.Errorf("round %d: rows of site %d = %+v", round, siteID, rows)
}
}
var mislabeled int64
db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang <> ?", second.ID, "fr").Count(&mislabeled)
if mislabeled != 0 {
t.Errorf("round %d: %d articles with the wrong language", round, mislabeled)
}
}
})
}
+251
View File
@@ -0,0 +1,251 @@
package test
import (
"RenewCMS/internal/domain/audit"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
func (e *localizationEnv) putJournal(t *testing.T, revision string, omit ...string) int {
t.Helper()
body := languagesBody(revision, "en", journalEntries(nil, omit...)...)
return e.owner(t, http.MethodPut, "/v1/site/journal/languages", body).Code
}
func decodeInto(t *testing.T, rec *httptest.ResponseRecorder, target any) bool {
t.Helper()
return json.Unmarshal(rec.Body.Bytes(), target) == nil
}
func TestLanguageLockContention(t *testing.T) {
t.Run("PUT answers 409 languages_changed when the site row cannot be locked", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
revision := e.revision(t, "journal")
rowsBefore, _ := e.store.snapshot()
e.sites.busy = true
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, "en", journalEntries(nil, "en-US")...))
expectSiteStatusAndBody(t, rec, http.StatusConflict, "languages_changed")
if rowsAfter, _ := e.store.snapshot(); !reflect.DeepEqual(rowsBefore, rowsAfter) || e.store.replaceCalls != 0 {
t.Errorf("something was written")
}
})
t.Run("A contention failure is recorded as a failed activity", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
revision := e.revision(t, "journal")
e.sites.busy = true
e.putJournal(t, revision, "en-US")
assertProducerFailure(t, e.single(t), "The languages changed since they were read.")
})
}
func TestLanguageSetupLocksUntilStable(t *testing.T) {
t.Run("A site created between the first read and the lock is locked too and has to be listed", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.sites.afterTryLock = func() { e.seedSite(8, "Fresh", "fresh", nil, nil, 2) }
rec := e.postSetup(t, everyPendingSite)
expectSiteStatusAndBody(t, rec, http.StatusUnprocessableEntity, "setup_incomplete")
if !reflect.DeepEqual(e.sites.tryLocked, [][]uint32{{1, 2, 3, 4}, {8}}) {
t.Errorf("locks = %v", e.sites.tryLocked)
}
if len(e.store.confirmedOrder) != 0 {
t.Errorf("confirmed = %v", e.store.confirmedOrder)
}
})
}
func TestLanguageSetupSkippedChoices(t *testing.T) {
t.Run("Choices for sites that already have a language are reported, not dropped", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
e.seedSite(notesSiteID, "Notes", "notes", nil, nil, 3)
rec := e.postSetup(t, `{"sites":[{"key":"notes","lang":"fr"},{"key":"journal","lang":"fr"}]}`)
var body struct {
Assigned int `json:"assigned"`
Skipped []string `json:"skipped"`
}
if rec.Code != http.StatusOK || !decodeInto(t, rec, &body) {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if body.Assigned != 3 || !reflect.DeepEqual(body.Skipped, []string{"journal"}) {
t.Errorf("body = %+v", body)
}
if view := e.revision(t, "journal"); view == "" || e.store.rows[journalSiteID][0].Tag != "en" {
t.Errorf("journal changed")
}
})
t.Run("A setup with nothing skipped lists an empty skipped array", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
rec := e.postSetup(t, everyPendingSite)
if fields := decodeFields(t, rec); string(fields["skipped"]) != "[]" {
t.Errorf("skipped = %s", fields["skipped"])
}
})
}
func TestLanguageInUseCountsSoftDeletedVersions(t *testing.T) {
t.Run("A language with only soft-deleted versions cannot be removed", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
e.store.trashed[journalSiteID] = map[string]int{"en-US": 1}
revision := e.revision(t, "journal")
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, "en", journalEntries(nil, "en-US")...))
expectSiteStatusAndBody(t, rec, http.StatusConflict, "language_in_use")
})
t.Run("Soft-deleted versions are not shown in the version count", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
e.store.trashed[journalSiteID] = map[string]int{"en-US": 1}
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/journal/languages", ""))
for _, entry := range view.Languages {
if entry.Lang == "en-US" && entry.VersionCount != 0 {
t.Errorf("version_count = %d", entry.VersionCount)
}
}
})
}
func TestLanguageActivity(t *testing.T) {
t.Run("A changed language set is recorded once", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
revision := e.revision(t, "journal")
if status := e.putJournal(t, revision, "en-US"); status != http.StatusOK {
t.Fatalf("status %d", status)
}
event := e.single(t)
assertProducerEvent(t, event, "site.languages_edit", audit.CategorySites, audit.OutcomeSuccess)
assertProducerTarget(t, event, "site", "1", "Journal")
assertProducerOwnerOrigin(t, event)
if len(event.Changes) != 1 || event.Changes[0].Field != audit.FieldLanguages {
t.Errorf("changes = %s", changesJSON(t, event))
}
})
t.Run("A default change is recorded as its own field", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
revision := e.revision(t, "journal")
body := languagesBody(revision, "fr", journalEntries(nil)...)
if rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", body); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
event := e.single(t)
if len(event.Changes) < 1 || event.Changes[0].Field != audit.FieldDefaultLanguage || *event.Changes[0].Before != "en" || *event.Changes[0].After != "fr" {
t.Errorf("changes = %s", changesJSON(t, event))
}
})
t.Run("A PUT that changes nothing records nothing", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
if status := e.putJournal(t, e.revision(t, "journal")); status != http.StatusOK {
t.Fatalf("status %d", status)
}
e.noEvent(t)
})
t.Run("A refused PUT is recorded as failed", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
e.putJournal(t, "stale")
assertProducerFailure(t, e.single(t), "The languages changed since they were read.")
})
t.Run("A read records nothing", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
e.read(t, "journal")
e.noEvent(t)
})
t.Run("A setup is recorded once per confirmed site", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
if rec := e.postSetup(t, everyPendingSite); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
events := e.events(t)
if len(events) != 4 {
t.Fatalf("events = %d", len(events))
}
for _, event := range events {
assertProducerEvent(t, event, "site.language_setup", audit.CategorySites, audit.OutcomeSuccess)
if len(event.Changes) != 1 || event.Changes[0].Field != audit.FieldDefaultLanguage {
t.Errorf("changes = %s", changesJSON(t, event))
}
}
if events[0].Params[audit.ParamAssigned] == nil {
t.Errorf("params = %v", events[0].Params)
}
wantLang := map[string]string{"Journal": "en", "Notes": "en", "Archive": "pt-BR", "Gallery": "en-GB"}
wantAssigned := map[string]int{"Journal": 8, "Notes": 3, "Archive": 5, "Gallery": 0}
for _, event := range events {
lang, ok := wantLang[event.Target.Name]
if !ok {
t.Fatalf("target = %q", event.Target.Name)
}
if after := event.Changes[0].After; after == nil || *after != lang {
t.Errorf("%s change = %s", event.Target.Name, changesJSON(t, event))
}
if got := fmt.Sprint(event.Params[audit.ParamAssigned]); got != fmt.Sprint(wantAssigned[event.Target.Name]) {
t.Errorf("%s assigned = %s", event.Target.Name, got)
}
}
})
t.Run("A refused setup is recorded once as failed", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.postSetup(t, `{"sites":[{"key":"journal","lang":"en"}]}`)
assertProducerFailure(t, e.single(t), "Every site that needs a language must be listed.")
})
t.Run("A setup refused by contention is recorded as failed", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedPendingSites()
e.sites.busy = true
e.postSetup(t, everyPendingSite)
assertProducerFailure(t, e.single(t), "Another change is running on this site. Please try again.")
})
}
+249
View File
@@ -0,0 +1,249 @@
package test
import (
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"reflect"
"testing"
"gorm.io/gorm"
)
const localizationFailureCallback = "test:fail-language-write"
var errInjectedLanguageWrite = errors.New("injected language write failure")
func failLanguageCreate(t *testing.T, db *gorm.DB, lang string) {
t.Helper()
err := db.Callback().Create().Before("gorm:create").Register(localizationFailureCallback, func(tx *gorm.DB) {
if row, ok := tx.Statement.Dest.(*entity.SiteLanguage); ok && row.Lang == lang {
_ = tx.AddError(errInjectedLanguageWrite)
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Create().Remove(localizationFailureCallback) })
}
func seedLocalizedSite(t *testing.T, db *gorm.DB, key string, rows []entity.SiteLanguage, articles map[string][]entity.Article) site.Site {
t.Helper()
created := mustCreateSite(t, persistence.NewSiteRepository(db), key, key, false)
for _, row := range rows {
row.SiteID = created.ID
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
for lang, group := range articles {
for _, stored := range group {
stored.SiteID = created.ID
stored.Lang = lang
stored.StoryID = fixtureStory()
stored.Slug = uniqueTestSlug()
if err := db.Create(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.Title == "deleted" {
if err := db.Delete(&stored).Error; err != nil {
t.Fatal(err)
}
}
}
}
return created
}
func storedLanguageRows(t *testing.T, db *gorm.DB, siteID uint32) []entity.SiteLanguage {
t.Helper()
var rows []entity.SiteLanguage
if err := db.Where("site_id = ?", siteID).Order("lang ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
for index := range rows {
rows[index].CreatedAt, rows[index].UpdatedAt = rows[index].CreatedAt.UTC(), rows[index].UpdatedAt.UTC()
}
return rows
}
func TestLocalizationPersistence(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
cases := []struct {
name string
run func(t *testing.T, db *gorm.DB)
}{
{"Persistence additions are safe to migrate", func(t *testing.T, db *gorm.DB) {
migrator := db.Migrator()
created := seedLocalizedSite(t, db, "migrating", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true}}, nil)
before := storedLanguageRows(t, db, created.ID)
for _, drop := range []struct {
model any
column string
}{{&entity.SiteLanguage{}, "social_locale"}, {&entity.Site{}, "public_url_pattern"}} {
if err := migrator.DropColumn(drop.model, drop.column); err != nil {
t.Fatalf("drop %s: %v", drop.column, err)
}
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
if !migrator.HasColumn(&entity.SiteLanguage{}, "social_locale") || !migrator.HasColumn(&entity.Site{}, "public_url_pattern") {
t.Fatalf("columns missing")
}
if pool, err := db.DB(); err == nil {
pool.SetMaxIdleConns(0)
}
after := storedLanguageRows(t, db, created.ID)
if len(after) != 1 || after[0].SocialLocale != nil || after[0].Lang != before[0].Lang {
t.Errorf("rows = %+v", after)
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("second migrate: %v", err)
}
if again := storedLanguageRows(t, db, created.ID); !reflect.DeepEqual(again, after) {
t.Errorf("second migrate changed rows")
}
}},
{"The public url pattern is stored and cleared", func(t *testing.T, db *gorm.DB) {
sites := persistence.NewSiteRepository(db)
created := mustCreateSite(t, sites, "Pattern", "pattern", false)
created.PublicURLPattern = localeOf("https://pattern.example.com/{lang}/{slug}")
saved, err := sites.Update(created)
if err != nil || !sameText(saved.PublicURLPattern, created.PublicURLPattern) {
t.Fatalf("saved = %+v, %v", saved, err)
}
created.PublicURLPattern = nil
cleared, err := sites.Update(created)
if err != nil || cleared.PublicURLPattern != nil {
t.Fatalf("cleared = %+v, %v", cleared, err)
}
}},
{"PUT replaces the set atomically, shows live versions and protects soft-deleted ones", func(t *testing.T, db *gorm.DB) {
fr := "fr_FR"
created := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{
{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true},
{Lang: "fr", Enabled: true, SocialLocale: &fr},
{Lang: "de", Enabled: true},
{Lang: "ar", Enabled: true},
{Lang: "es"},
}, map[string][]entity.Article{
"en": {{Title: "a", IsOnline: true}},
"ar": {{Title: "draft"}},
"es": {{Title: "deleted"}},
})
update := useCases.NewUpdateSiteLanguagesUseCase(persistence.NewTransactor(db), discardRecorder)
read := useCases.NewGetSiteLanguagesUseCase(persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db))
view, err := read.Get("journal")
if err != nil {
t.Fatal(err)
}
entries := func(omit ...string) []useCases.SiteLanguageEntry {
all := []useCases.SiteLanguageEntry{{Lang: "en", Enabled: true}, {Lang: "fr", Enabled: true, SocialLocale: &fr}, {Lang: "de", Enabled: false}, {Lang: "ar", Enabled: true}, {Lang: "es"}}
kept := []useCases.SiteLanguageEntry{}
for _, entry := range all {
if !containsTag(omit, entry.Lang) {
kept = append(kept, entry)
}
}
return kept
}
command := func(def string, list []useCases.SiteLanguageEntry) useCases.UpdateSiteLanguagesCommand {
return useCases.UpdateSiteLanguagesCommand{Key: "journal", Revision: view.Revision, Default: def, Languages: list}
}
if _, err := update.Update(context.Background(), command("en", entries("ar"))); !errors.Is(err, localization.ErrLanguageInUse) {
t.Fatalf("draft language: %v", err)
}
if _, err := update.Update(context.Background(), command("en", entries("es"))); !errors.Is(err, localization.ErrLanguageInUse) {
t.Fatalf("soft-deleted language: %v", err)
}
for _, entry := range view.Languages {
if entry.Lang == "es" && entry.VersionCount != 0 {
t.Errorf("soft-deleted version shown: %+v", entry)
}
}
before := storedLanguageRows(t, db, created.ID)
failLanguageCreate(t, db, "pt")
withNew := append(entries(), useCases.SiteLanguageEntry{Lang: "pt", Enabled: true})
if _, err := update.Update(context.Background(), command("fr", withNew)); !errors.Is(err, errInjectedLanguageWrite) {
t.Fatalf("injected failure: %v", err)
}
if after := storedLanguageRows(t, db, created.ID); !reflect.DeepEqual(before, after) {
t.Errorf("rows changed after a failed write")
}
done, err := update.Update(context.Background(), command("fr", entries("de")))
if err != nil {
t.Fatal(err)
}
if done.Default == nil || *done.Default != "fr" || len(done.Languages) != 4 {
t.Errorf("view = %+v", done)
}
if _, err := update.Update(context.Background(), command("en", entries())); !errors.Is(err, localization.ErrLanguagesChanged) {
t.Errorf("stale: %v", err)
}
}},
{"Setup assigns every article including soft-deleted ones in one transaction", func(t *testing.T, db *gorm.DB) {
first := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true}}, map[string][]entity.Article{"": {{Title: "a"}, {Title: "deleted"}}})
second := seedLocalizedSite(t, db, "notes", nil, map[string][]entity.Article{"": {{Title: "b"}}})
apply := useCases.NewApplyLanguageSetupUseCase(persistence.NewTransactor(db), discardRecorder)
choices := []localization.SetupChoice{{Key: "default", Lang: "en"}, {Key: "journal", Lang: "en"}, {Key: "notes", Lang: "fr"}}
failLanguageCreate(t, db, "fr")
if _, err := apply.Apply(context.Background(), choices); !errors.Is(err, errInjectedLanguageWrite) {
t.Fatalf("injected failure: %v", err)
}
var unassigned int64
db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned)
if unassigned != 3 || len(storedLanguageRows(t, db, first.ID)) != 1 || storedLanguageRows(t, db, first.ID)[0].Confirmed {
t.Fatalf("failed setup left a trace: %d unassigned", unassigned)
}
_ = db.Callback().Create().Remove(localizationFailureCallback)
result, err := apply.Apply(context.Background(), choices)
if err != nil || result.Assigned != 3 {
t.Fatalf("result = %+v, %v", result, err)
}
rows := storedLanguageRows(t, db, second.ID)
if len(rows) != 1 || rows[0].Lang != "fr" || !rows[0].IsDefault || !rows[0].Confirmed {
t.Errorf("rows = %+v", rows)
}
db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned)
if unassigned != 0 {
t.Errorf("%d articles unassigned", unassigned)
}
again, err := apply.Apply(context.Background(), choices)
if err != nil || again.Assigned != 0 || len(again.Sites) != 0 {
t.Errorf("retry = %+v, %v", again, err)
}
}},
}
for _, backend := range backends {
for _, c := range cases {
t.Run(backend.name+"/"+c.name, func(t *testing.T) {
c.run(t, backend.open(t))
})
}
}
}
func containsTag(tags []string, tag string) bool {
for _, candidate := range tags {
if candidate == tag {
return true
}
}
return false
}
+328
View File
@@ -0,0 +1,328 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/useCases"
"context"
"encoding/json"
"errors"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
const (
journalSiteID = 1
notesSiteID = 2
archiveSiteID = 3
gallerySiteID = 4
readerUserID = 7
)
var errInjectedConfirm = errors.New("injected confirm failure")
type fakeLocalizationStore struct {
articles *mockArticleRepo
rows map[uint32][]localization.Row
trashed map[uint32]map[string]int
failConfirmAt int
confirmCalls int
replaceCalls int
nextArticleID uint32
confirmedOrder []uint32
}
var _ localization.Repository = &fakeLocalizationStore{}
func newFakeLocalizationStore(articles *mockArticleRepo) *fakeLocalizationStore {
return &fakeLocalizationStore{articles: articles, rows: map[uint32][]localization.Row{}, trashed: map[uint32]map[string]int{}, nextArticleID: 100}
}
func (f *fakeLocalizationStore) Rows(siteID uint32) ([]localization.Row, error) {
return slices.Clone(f.rows[siteID]), nil
}
func (f *fakeLocalizationStore) ConfirmedSites() (map[uint32]bool, error) {
confirmed := map[uint32]bool{}
for siteID, rows := range f.rows {
if !localization.NeedsSetup(rows) {
confirmed[siteID] = true
}
}
return confirmed, nil
}
func (f *fakeLocalizationStore) VersionCounts(siteID uint32) (map[string]localization.VersionCount, error) {
counts := map[string]localization.VersionCount{}
for _, stored := range f.articles.articles {
if stored.SiteID != siteID {
continue
}
count := counts[stored.Lang]
count.Total++
if stored.IsOnline {
count.Published++
}
counts[stored.Lang] = count
}
for lang, trashed := range f.trashed[siteID] {
count := counts[lang]
count.Trashed = trashed
counts[lang] = count
}
return counts, nil
}
func (f *fakeLocalizationStore) Replace(siteID uint32, rows []localization.Row) error {
f.replaceCalls++
f.rows[siteID] = slices.Clone(rows)
return nil
}
func (f *fakeLocalizationStore) ConfirmSetup(siteID uint32, tag string) error {
f.confirmCalls++
if f.failConfirmAt == f.confirmCalls {
return errInjectedConfirm
}
f.confirmedOrder = append(f.confirmedOrder, siteID)
f.rows[siteID] = []localization.Row{{Tag: tag, IsDefault: true, Enabled: true, Confirmed: true}}
return nil
}
func (f *fakeLocalizationStore) snapshot() (map[uint32][]localization.Row, map[uint32]article.Article) {
rows := make(map[uint32][]localization.Row, len(f.rows))
for siteID, stored := range f.rows {
rows[siteID] = slices.Clone(stored)
}
return rows, maps.Clone(f.articles.articles)
}
type rollbackLocalizationTransactor struct {
store *fakeLocalizationStore
sites site.Repository
runs int
}
var _ transaction.Transactor = &rollbackLocalizationTransactor{}
func (r *rollbackLocalizationTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
r.runs++
rows, articles := r.store.snapshot()
err := work(transaction.Repositories{Articles: r.store.articles, Sites: r.sites, Localization: r.store})
if err != nil {
r.store.rows = rows
r.store.articles.articles = articles
}
return err
}
type patternSiteRepo struct {
*mockSiteRepo
}
func (p patternSiteRepo) Update(s site.Site) (site.Site, error) {
saved, err := p.mockSiteRepo.Update(s)
if err != nil {
return saved, err
}
stored := p.mockSiteRepo.sites[s.ID]
stored.PublicURLPattern = s.PublicURLPattern
p.mockSiteRepo.sites[s.ID] = stored
return p.mockSiteRepo.withCount(stored), nil
}
type localizationEnv struct {
*siteEnv
store *fakeLocalizationStore
transactor *rollbackLocalizationTransactor
nextID uint32
}
func newLocalizationEnv(t *testing.T) *localizationEnv {
t.Helper()
e := newSiteEnv(t)
store := newFakeLocalizationStore(e.articles)
sites := patternSiteRepo{e.sites}
transactor := &rollbackLocalizationTransactor{store: store, sites: sites}
c := e.container
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder)
c.GetSiteLanguagesUseCase = useCases.NewGetSiteLanguagesUseCase(sites, store)
c.UpdateSiteLanguagesUseCase = useCases.NewUpdateSiteLanguagesUseCase(transactor, e.audit.recorder)
c.GetLanguageSetupUseCase = useCases.NewGetLanguageSetupUseCase(sites, store)
c.ApplyLanguageSetupUseCase = useCases.NewApplyLanguageSetupUseCase(transactor, e.audit.recorder)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
e.router = router
e.addUser(readerUserID, writerRoleID, true)
return &localizationEnv{siteEnv: e, store: store, transactor: transactor, nextID: 1}
}
func (e *localizationEnv) addArticles(siteID uint32, lang string, total int, published int) {
for index := 0; index < total; index++ {
e.store.nextArticleID++
e.articles.articles[e.store.nextArticleID] = article.Article{ID: e.store.nextArticleID, Title: "Article " + lang, SiteID: siteID, Lang: lang, IsOnline: index < published}
}
}
func (e *localizationEnv) seedRows(siteID uint32, rows ...localization.Row) {
e.store.rows[siteID] = rows
}
func (e *localizationEnv) seedJournal() {
e.sites.seed(journalSiteID, "Journal", "journal", true)
english := "en"
site := e.sites.sites[journalSiteID]
site.Locale = &english
e.sites.sites[journalSiteID] = site
frenchSocial := "fr_FR"
e.seedRows(journalSiteID,
localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
localization.Row{Tag: "fr", Enabled: true, Confirmed: true, SocialLocale: &frenchSocial},
localization.Row{Tag: "de", Enabled: true},
localization.Row{Tag: "ar", Enabled: true},
localization.Row{Tag: "es"},
localization.Row{Tag: "en-US"},
)
e.addArticles(journalSiteID, "en", 7, 6)
e.addArticles(journalSiteID, "fr", 4, 2)
e.addArticles(journalSiteID, "de", 4, 3)
e.addArticles(journalSiteID, "ar", 1, 0)
e.addArticles(journalSiteID, "es", 1, 0)
}
func (e *localizationEnv) seedPendingSites() {
e.seedJournalPending()
e.seedSite(notesSiteID, "Notes", "notes", nil, nil, 3)
pt := "pt-BR"
e.seedSite(archiveSiteID, "Archive", "archive", &pt, &localization.Row{Tag: "pt-BR", IsDefault: true, Enabled: true}, 5)
gb := "en-GB"
e.seedSite(gallerySiteID, "Gallery", "gallery", &gb, &localization.Row{Tag: "en-GB", IsDefault: true, Enabled: true}, 0)
}
func (e *localizationEnv) seedJournalPending() {
en := "en"
e.seedSite(journalSiteID, "Journal", "journal", &en, &localization.Row{Tag: "en", IsDefault: true, Enabled: true}, 8)
}
func (e *localizationEnv) seedSite(id uint32, name string, key string, locale *string, row *localization.Row, articles int) {
e.sites.seed(id, name, key, id == journalSiteID)
stored := e.sites.sites[id]
stored.Locale = locale
e.sites.sites[id] = stored
if row != nil {
e.seedRows(id, *row)
}
e.addArticles(id, language.Unassigned, articles, 0)
}
func (e *localizationEnv) read(t *testing.T, key string) (int, map[string]json.RawMessage) {
t.Helper()
rec := e.owner(t, http.MethodGet, "/v1/site/"+key+"/languages", "")
if rec.Code != http.StatusOK {
return rec.Code, nil
}
return rec.Code, decodeFields(t, rec)
}
func decodeFields(t *testing.T, rec *httptest.ResponseRecorder) map[string]json.RawMessage {
t.Helper()
var fields map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &fields); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return fields
}
func (e *localizationEnv) revision(t *testing.T, key string) string {
t.Helper()
_, fields := e.read(t, key)
var revision string
if err := json.Unmarshal(fields["revision"], &revision); err != nil {
t.Fatalf("revision: %v", err)
}
return revision
}
type languagesView struct {
Revision string `json:"revision"`
Default *string `json:"default"`
NeedsSetup bool `json:"needs_setup"`
Languages []struct {
Lang string `json:"lang"`
Enabled bool `json:"enabled"`
IsDefault bool `json:"is_default"`
VersionCount int `json:"version_count"`
PublishedCount int `json:"published_count"`
SocialLocale *string `json:"social_locale"`
State string `json:"social_locale_state"`
} `json:"languages"`
Available []struct {
Lang string `json:"lang"`
Substitute *string `json:"substitute"`
} `json:"available"`
}
func decodeLanguagesView(t *testing.T, rec *httptest.ResponseRecorder) languagesView {
t.Helper()
var view languagesView
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return view
}
func (v languagesView) tags() []string {
tags := []string{}
for _, entry := range v.Languages {
tags = append(tags, entry.Lang)
}
return tags
}
func keysOf(fields map[string]json.RawMessage) []string {
keys := slices.Sorted(maps.Keys(fields))
return keys
}
func languagesBody(revision string, defaultTag string, entries ...string) string {
return `{"revision":"` + revision + `","default":"` + defaultTag + `","languages":[` + strings.Join(entries, ",") + `]}`
}
func entryJSON(lang string, enabled bool, social string) string {
enabledText := "false"
if enabled {
enabledText = "true"
}
return `{"lang":"` + lang + `","enabled":` + enabledText + `,"social_locale":` + social + `}`
}
func journalEntries(overrides map[string]string, omit ...string) []string {
base := []struct {
lang string
enabled bool
social string
}{{"en", true, "null"}, {"fr", true, `"fr_FR"`}, {"de", true, "null"}, {"ar", true, "null"}, {"es", false, "null"}, {"en-US", false, "null"}}
entries := []string{}
for _, row := range base {
if slices.Contains(omit, row.lang) {
continue
}
if replacement, ok := overrides[row.lang]; ok {
entries = append(entries, replacement)
continue
}
entries = append(entries, entryJSON(row.lang, row.enabled, row.social))
}
return entries
}
+74
View File
@@ -0,0 +1,74 @@
package test
import (
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"context"
"errors"
"fmt"
"testing"
mysqldriver "github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgconn"
)
type codedSQLiteError struct{ code int }
func (c codedSQLiteError) Error() string { return fmt.Sprintf("sqlite code %d", c.code) }
func (c codedSQLiteError) Code() int { return c.code }
func TestDriverLockContention(t *testing.T) {
cases := []struct {
name string
driver string
err error
want bool
}{
{"sqlite busy", database.DriverSQLite, codedSQLiteError{5}, true},
{"sqlite busy snapshot", database.DriverSQLite, fmt.Errorf("wrapped: %w", codedSQLiteError{5 | 2<<8}), true},
{"sqlite locked", database.DriverSQLite, codedSQLiteError{6}, true},
{"sqlite constraint", database.DriverSQLite, codedSQLiteError{19}, false},
{"sqlite plain error", database.DriverSQLite, errors.New("database is locked"), false},
{"mysql nowait", database.DriverMySQL, &mysqldriver.MySQLError{Number: 3572}, true},
{"mysql lock wait timeout", database.DriverMySQL, fmt.Errorf("wrapped: %w", &mysqldriver.MySQLError{Number: 1205}), true},
{"mysql duplicate", database.DriverMySQL, &mysqldriver.MySQLError{Number: 1062}, false},
{"postgres lock not available", database.DriverPostgres, &pgconn.PgError{Code: "55P03"}, true},
{"postgres unique violation", database.DriverPostgres, &pgconn.PgError{Code: "23505"}, false},
{"unknown driver", "oracle", codedSQLiteError{5}, false},
{"nil error", database.DriverSQLite, nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := database.IsLockContentionFor(c.driver, c.err); got != c.want {
t.Errorf("got %v, want %v", got, c.want)
}
})
}
}
func TestTransactorClassifiesLockContention(t *testing.T) {
db := openContractSQLite(t)
transactor := persistence.NewTransactor(db)
run := func(failure error) error {
return transactor.Run(context.Background(), func(transaction.Repositories) error { return failure })
}
t.Run("a busy error becomes the domain sentinel and keeps its cause", func(t *testing.T) {
cause := codedSQLiteError{5}
err := run(cause)
if !errors.Is(err, transaction.ErrLockContention) || !errors.Is(err, cause) {
t.Errorf("err = %v", err)
}
})
t.Run("another error is returned untouched", func(t *testing.T) {
other := errors.New("other")
if err := run(other); err != other {
t.Errorf("err = %v", err)
}
})
t.Run("success stays nil", func(t *testing.T) {
if err := run(nil); err != nil {
t.Errorf("err = %v", err)
}
})
}
+485
View File
@@ -0,0 +1,485 @@
package test
import (
"RenewCMS/internal/domain/localization"
"net/http"
"reflect"
"slices"
"testing"
)
var languagesReadKeys = []string{"available", "default", "languages", "needs_setup", "revision"}
func TestSiteLanguagesReading(t *testing.T) {
t.Run("Read the languages", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rec := e.as(t, readerUserID, http.MethodGet, "/v1/site/journal/languages", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if keys := keysOf(decodeFields(t, rec)); !reflect.DeepEqual(keys, languagesReadKeys) {
t.Errorf("keys = %v", keys)
}
view := decodeLanguagesView(t, rec)
if view.Revision == "" || view.Default == nil || *view.Default != "en" || view.NeedsSetup {
t.Errorf("header = %+v", view)
}
if !reflect.DeepEqual(view.tags(), []string{"en", "ar", "de", "fr", "en-US", "es"}) {
t.Errorf("order = %v", view.tags())
}
counts := map[string][2]int{"en": {7, 6}, "fr": {4, 2}, "de": {4, 3}, "ar": {1, 0}, "es": {1, 0}, "en-US": {0, 0}}
for _, entry := range view.Languages {
if got := [2]int{entry.VersionCount, entry.PublishedCount}; got != counts[entry.Lang] {
t.Errorf("%s counts = %v", entry.Lang, got)
}
}
if available := view.Available; len(available) == 0 || !slices.ContainsFunc(available, func(o struct {
Lang string `json:"lang"`
Substitute *string `json:"substitute"`
}) bool {
return o.Lang == "pt-PT" && o.Substitute != nil && *o.Substitute == "pt"
}) {
t.Errorf("available = %+v", available)
}
})
t.Run("Read access", func(t *testing.T) {
cases := []struct {
name string
user uint32
key string
status int
}{
{"an anonymous client", 0, "journal", http.StatusUnauthorized},
{"a reader", readerUserID, "journal", http.StatusOK},
{"an owner", apiKeyOwnerId, "journal", http.StatusOK},
{"an owner on an unknown site", apiKeyOwnerId, "nowhere", http.StatusNotFound},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rec := e.as(t, c.user, http.MethodGet, "/v1/site/"+c.key+"/languages", "")
if rec.Code != c.status {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if c.status == http.StatusNotFound && bodyText(rec) != "site_not_found" {
t.Errorf("body = %q", bodyText(rec))
}
})
}
})
t.Run("Social locale states", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(5, "Atelier", "atelier", false)
fr := "fr_FR"
e.seedRows(5,
localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
localization.Row{Tag: "en-GB", Enabled: true},
localization.Row{Tag: "fr", Enabled: true, SocialLocale: &fr},
localization.Row{Tag: "pt-BR", Enabled: true},
)
cases := []struct {
lang string
state string
explicit *string
effective *string
}{
{"en-GB", "derived", nil, localeOf("en_GB")},
{"pt-BR", "derived", nil, localeOf("pt_BR")},
{"fr", "set", &fr, &fr},
{"en", "not_set", nil, nil},
}
rec := e.owner(t, http.MethodGet, "/v1/site/atelier/languages", "")
view := decodeLanguagesView(t, rec)
for _, c := range cases {
t.Run(c.lang, func(t *testing.T) {
for _, entry := range view.Languages {
if entry.Lang != c.lang {
continue
}
if entry.State != c.state || !sameText(entry.SocialLocale, c.explicit) {
t.Errorf("entry = %+v", entry)
}
row := localization.Row{Tag: entry.Lang, SocialLocale: entry.SocialLocale}
if !sameText(localization.EffectiveSocialLocale(row), c.effective) {
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row))
}
}
})
}
})
t.Run("Only the explicit value and the tag's own region resolve the social locale", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(5, "Atelier", "atelier", false)
gb := "en-GB"
stored := e.sites.sites[5]
stored.Locale = &gb
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" {
t.Errorf("state = %q", view.Languages[0].State)
}
})
t.Run("A site that still needs setup", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(2, "Notes", "notes", false)
before := e.store.replaceCalls
view := decodeLanguagesView(t, e.as(t, readerUserID, http.MethodGet, "/v1/site/notes/languages", ""))
put := e.owner(t, http.MethodPut, "/v1/site/notes/languages", languagesBody("any", "en", entryJSON("en", true, "null")))
if !view.NeedsSetup {
t.Errorf("needs_setup = false")
}
expectSiteStatusAndBody(t, put, http.StatusConflict, "setup_required")
if e.store.replaceCalls != before {
t.Errorf("something was stored")
}
})
t.Run("Sites created after the migration have no language row", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(2, "Fresh", "fresh", false)
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/fresh/languages", ""))
if !view.NeedsSetup || len(view.Languages) != 0 || view.Default != nil {
t.Errorf("view = %+v", view)
}
})
}
func sameText(left *string, right *string) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func TestSiteLanguagesWriting(t *testing.T) {
put := func(e *localizationEnv, t *testing.T, defaultTag string, entries []string) (string, string) {
t.Helper()
revision := e.revision(t, "journal")
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, defaultTag, entries...))
return rec.Body.String(), http.StatusText(rec.Code)
}
_ = put
changes := []struct {
name string
def string
entries []string
check func(t *testing.T, view languagesView)
}{
{"fr added with enabled true", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_FR"`)}), func(t *testing.T, v languagesView) {
if countTag(v, "fr") != 1 || !findLanguage(t, v, "fr").Enabled {
t.Errorf("fr = %+v", v.tags())
}
}},
{"pt-br added (lowercase) as enabled", "en", append(journalEntries(nil), entryJSON("pt-br", true, "null")), func(t *testing.T, v languagesView) {
if countTag(v, "pt-BR") != 1 || countTag(v, "pt-br") != 0 {
t.Errorf("tags = %v", v.tags())
}
}},
{"default fr", "fr", journalEntries(nil), func(t *testing.T, v languagesView) {
if !findLanguage(t, v, "fr").IsDefault || findLanguage(t, v, "en").IsDefault || !findLanguage(t, v, "en").Enabled {
t.Errorf("view = %+v", v.Languages)
}
}},
{"de enabled false", "en", journalEntries(map[string]string{"de": entryJSON("de", false, "null")}), func(t *testing.T, v languagesView) {
de := findLanguage(t, v, "de")
if de.Enabled || de.VersionCount != 4 || de.PublishedCount != 3 {
t.Errorf("de = %+v", de)
}
}},
{"es enabled true", "en", journalEntries(map[string]string{"es": entryJSON("es", true, "null")}), func(t *testing.T, v languagesView) {
es := findLanguage(t, v, "es")
if !es.Enabled || es.VersionCount != 1 {
t.Errorf("es = %+v", es)
}
}},
{"en-US omitted", "en", journalEntries(nil, "en-US"), func(t *testing.T, v languagesView) {
if countTag(v, "en-US") != 0 {
t.Errorf("tags = %v", v.tags())
}
}},
{"fr social_locale fr_CA", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_CA"`)}), func(t *testing.T, v languagesView) {
fr := findLanguage(t, v, "fr")
if fr.State != "set" || fr.SocialLocale == nil || *fr.SocialLocale != "fr_CA" {
t.Errorf("fr = %+v", fr)
}
}},
{"fr social_locale null", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, "null")}), func(t *testing.T, v languagesView) {
if findLanguage(t, v, "fr").State != "not_set" {
t.Errorf("fr = %+v", findLanguage(t, v, "fr"))
}
}},
}
for _, c := range changes {
t.Run("Changing the set/"+c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
articlesBefore := len(e.articles.articles)
revision := e.revision(t, "journal")
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, c.def, c.entries...))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
c.check(t, decodeLanguagesView(t, rec))
if len(e.articles.articles) != articlesBefore {
t.Errorf("articles changed")
}
for _, stored := range e.articles.articles {
if stored.Lang == "" {
t.Errorf("an article lost its language")
}
}
})
}
rejections := []struct {
name string
def string
entries func() []string
status int
code string
}{
{"default en with en enabled false", "en", func() []string {
return journalEntries(map[string]string{"en": entryJSON("en", false, "null")})
}, 422, "default_not_enabled"},
{"default ja", "ja", func() []string { return journalEntries(nil) }, 422, "default_not_enabled"},
{"every language enabled false", "en", func() []string {
entries := []string{}
for _, tag := range []string{"en", "fr", "de", "ar", "es", "en-US"} {
entries = append(entries, entryJSON(tag, false, "null"))
}
return entries
}, 422, "no_enabled_language"},
{"languages []", "en", func() []string { return nil }, 422, "no_enabled_language"},
{"es omitted (1 version)", "en", func() []string { return journalEntries(nil, "es") }, 409, "language_in_use"},
{"ar omitted (1 draft or offline version)", "en", func() []string { return journalEntries(nil, "ar") }, 409, "language_in_use"},
{"en omitted", "en", func() []string { return journalEntries(nil, "en") }, 422, "default_not_enabled"},
{"a language english", "en", func() []string { return append(journalEntries(nil), entryJSON("english", true, "null")) }, 422, "invalid_language"},
{"a language xx", "en", func() []string { return append(journalEntries(nil), entryJSON("xx", true, "null")) }, 422, "unsupported_language"},
{"a language pt-PT", "en", func() []string { return append(journalEntries(nil), entryJSON("pt-PT", true, "null")) }, 422, "unsupported_language"},
{"the same tag twice", "en", func() []string {
return append(journalEntries(nil), entryJSON("fr", true, "null"), entryJSON("FR", true, "null"))
}, 422, "duplicate_language"},
{"fr social_locale fr-FR", "en", func() []string { return socialOnFrench(`"fr-FR"`) }, 422, "invalid_social_locale"},
{"fr social_locale fr", "en", func() []string { return socialOnFrench(`"fr"`) }, 422, "invalid_social_locale"},
{"fr social_locale empty", "en", func() []string { return socialOnFrench(`""`) }, 422, "invalid_social_locale"},
{"fr social_locale pt_BR", "en", func() []string { return socialOnFrench(`"pt_BR"`) }, 422, "invalid_social_locale"},
{"fr default, de disabled and es omitted", "fr", func() []string {
return journalEntries(map[string]string{"de": entryJSON("de", false, "null")}, "es")
}, 409, "language_in_use"},
}
for _, c := range rejections {
t.Run("Rules that reject a change/"+c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rowsBefore, articlesBefore := e.store.snapshot()
revision := e.revision(t, "journal")
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, c.def, c.entries()...))
expectSiteStatusAndBody(t, rec, c.status, c.code)
rowsAfter, articlesAfter := e.store.snapshot()
if !reflect.DeepEqual(rowsBefore, rowsAfter) || !reflect.DeepEqual(articlesBefore, articlesAfter) || e.store.replaceCalls != 0 {
t.Errorf("something was stored")
}
})
}
t.Run("Rules that reject a change/no revision key", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", `{"default":"en","languages":[`+entryJSON("en", true, "null")+`]}`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body.")
})
t.Run("Rules that reject a change/malformed JSON", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", `{not json`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body.")
})
t.Run("Social locale format and language part", func(t *testing.T) {
cases := []struct {
tag string
social string
valid bool
}{
{"fr", "fr_FR", true},
{"fr", "fr_CA", true},
{"fr", "pt_BR", false},
{"fr-CA", "fr_CA", true},
{"ar", "ara_EG", false},
{"fr", "fr_F", false},
{"fr", "fr__FR", false},
{"fr", "fr_F1", false},
}
for _, c := range cases {
social := c.social
_, err := localization.NormalizeSocialLocale(c.tag, &social)
if (err == nil) != c.valid {
t.Errorf("%s on %s: err = %v", c.social, c.tag, err)
}
}
if normalized, err := localization.NormalizeSocialLocale("fr", localeOf("fr_fr")); err != nil || *normalized != "fr_FR" {
t.Errorf("normalized = %v, %v", normalized, err)
}
})
t.Run("A stale revision is refused", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
stale := e.revision(t, "journal")
other := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(stale, "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_CA"`)})...))
if other.Code != http.StatusOK {
t.Fatalf("other owner: %d %s", other.Code, other.Body)
}
rowsBefore, _ := e.store.snapshot()
refused := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(stale, "fr", journalEntries(nil)...))
expectSiteStatusAndBody(t, refused, http.StatusConflict, "languages_changed")
if rowsAfter, _ := e.store.snapshot(); !reflect.DeepEqual(rowsBefore, rowsAfter) {
t.Errorf("rows changed")
}
retry := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "fr", journalEntries(nil)...))
if retry.Code != http.StatusOK {
t.Errorf("retry: %d %s", retry.Code, retry.Body)
}
})
t.Run("Sending the same state again is a no-op", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
first := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "fr", journalEntries(nil)...))
r2 := decodeLanguagesView(t, first).Revision
calls := e.store.replaceCalls
rowsBefore, _ := e.store.snapshot()
again := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(r2, "fr", journalEntries(nil)...))
if again.Code != http.StatusOK || decodeLanguagesView(t, again).Revision != r2 {
t.Fatalf("again: %d %s", again.Code, again.Body)
}
if rowsAfter, _ := e.store.snapshot(); e.store.replaceCalls != calls || !reflect.DeepEqual(rowsBefore, rowsAfter) {
t.Errorf("rows were written again")
}
})
t.Run("Only an owner can write", func(t *testing.T) {
cases := []struct {
name string
user uint32
status int
}{
{"an anonymous client", 0, http.StatusUnauthorized},
{"a reader", readerUserID, http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
revision := e.revision(t, "journal")
rec := e.as(t, c.user, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, "fr", journalEntries(nil)...))
if rec.Code != c.status || e.store.replaceCalls != 0 {
t.Errorf("status %d, writes %d", rec.Code, e.store.replaceCalls)
}
})
}
})
t.Run("Removing a language is decided under the site lock", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "en", journalEntries(nil, "en-US")...))
if !slices.Equal(e.sites.lockedSites, []uint32{journalSiteID}) || e.transactor.runs != 1 {
t.Errorf("locks %v, transactions %d", e.sites.lockedSites, e.transactor.runs)
}
})
}
func socialOnFrench(social string) []string {
return journalEntries(map[string]string{"fr": entryJSON("fr", true, social)})
}
func countTag(view languagesView, tag string) int {
total := 0
for _, entry := range view.Languages {
if entry.Lang == tag {
total++
}
}
return total
}
type languageEntryView = struct {
Lang string `json:"lang"`
Enabled bool `json:"enabled"`
IsDefault bool `json:"is_default"`
VersionCount int `json:"version_count"`
PublishedCount int `json:"published_count"`
SocialLocale *string `json:"social_locale"`
State string `json:"social_locale_state"`
}
func findLanguage(t *testing.T, view languagesView, tag string) languageEntryView {
t.Helper()
for _, entry := range view.Languages {
if entry.Lang == tag {
return entry
}
}
t.Fatalf("language %s missing from %v", tag, view.tags())
return languageEntryView{}
}
func TestSiteLanguagesRevision(t *testing.T) {
base := []localization.Row{{Tag: "en", IsDefault: true, Enabled: true}, {Tag: "fr", Enabled: true}}
other := func(change func(rows []localization.Row)) string {
rows := slices.Clone(base)
change(rows)
return localization.Revision(rows)
}
reference := localization.Revision(base)
t.Run("the same rows give the same revision whatever the order", func(t *testing.T) {
if localization.Revision([]localization.Row{base[1], base[0]}) != reference {
t.Errorf("order changed the revision")
}
})
t.Run("any change of lang, default, enabled or social_locale gives another one", func(t *testing.T) {
changes := map[string]func(rows []localization.Row){
"lang": func(rows []localization.Row) { rows[1].Tag = "de" },
"default": func(rows []localization.Row) { rows[1].IsDefault = true },
"enabled": func(rows []localization.Row) { rows[1].Enabled = false },
"social": func(rows []localization.Row) { rows[1].SocialLocale = localeOf("fr_FR") },
}
for name, change := range changes {
if other(change) == reference {
t.Errorf("%s did not change the revision", name)
}
}
})
t.Run("confirmed flags do not change the revision", func(t *testing.T) {
if other(func(rows []localization.Row) { rows[0].Confirmed = true }) != reference {
t.Errorf("confirmed changed the revision")
}
})
}
+231
View File
@@ -0,0 +1,231 @@
package test
import (
"RenewCMS/internal/domain/site"
"encoding/json"
"net/http"
"strings"
"testing"
)
const journalPattern = "https://journal.example.com/{lang}/{slug}"
func patternBody(value string) string {
encoded, _ := json.Marshal(value)
return `{"public_url_pattern":` + string(encoded) + `}`
}
func newPatternEnv(t *testing.T) *localizationEnv {
t.Helper()
e := newLocalizationEnv(t)
e.sites.seed(siteMainID, "Main", "main", true)
return e
}
func TestSitePublicURLPattern(t *testing.T) {
t.Run("Reading the pattern", func(t *testing.T) {
e := newPatternEnv(t)
stored := e.sites.sites[siteMainID]
pattern := journalPattern
stored.PublicURLPattern = &pattern
e.sites.sites[siteMainID] = stored
rec := e.as(t, readerUserID, http.MethodGet, "/v1/site/", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var items []map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatal(err)
}
if string(items[0]["public_url_pattern"]) != `"`+journalPattern+`"` {
t.Errorf("pattern = %s", items[0]["public_url_pattern"])
}
for _, key := range []string{"id", "name", "key", "base_url", "default_social_image_id", "default_robots", "locale", "is_default", "article_count", "site_name", "title_template", "default_description"} {
if _, present := items[0][key]; !present {
t.Errorf("key %q missing", key)
}
}
})
writes := []struct {
name string
raw string
want *string
start string
}{
{"a pattern with lang and slug", patternBody(journalPattern), localeOf(journalPattern), ""},
{"a pattern with a path", patternBody("https://journal.example.com/{lang}/articles/{slug}"), localeOf("https://journal.example.com/{lang}/articles/{slug}"), ""},
{"a local http pattern", patternBody("http://localhost:3000/{slug}"), localeOf("http://localhost:3000/{slug}"), ""},
{"a repeated placeholder", patternBody("https://journal.example.com/{slug}/{slug}"), localeOf("https://journal.example.com/{slug}/{slug}"), ""},
{"null", `{"public_url_pattern":null}`, nil, journalPattern},
{"an empty string", patternBody(""), nil, journalPattern},
{"a padded pattern is trimmed", patternBody(" https://journal.example.com/{slug} "), localeOf("https://journal.example.com/{slug}"), ""},
}
for _, c := range writes {
t.Run("Writing the pattern/"+c.name, func(t *testing.T) {
e := newPatternEnv(t)
if c.start != "" {
stored := e.sites.sites[siteMainID]
stored.PublicURLPattern = localeOf(c.start)
e.sites.sites[siteMainID] = stored
}
rec := e.owner(t, http.MethodPatch, "/v1/site/1", c.raw)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if got := decodeSiteBody(t, rec).PublicURLPattern; !sameText(got, c.want) {
t.Errorf("returned = %v", got)
}
if !sameText(e.sites.sites[siteMainID].PublicURLPattern, c.want) {
t.Errorf("stored = %v", e.sites.sites[siteMainID].PublicURLPattern)
}
})
}
refusals := []struct {
value string
code string
}{
{"ftp://journal.example.com/{lang}/{slug}", "invalid_scheme"},
{"journal.example.com/{slug}", "invalid_scheme"},
{"https:///{slug}", "invalid_host"},
{"https://journal.example.com/{language}/{slug}", "unknown_placeholder"},
{"https://journal.example.com/{lang", "unknown_placeholder"},
{"https://journal.example.com/my pages/{lang}/{slug}", "not_encoded"},
{"https://journal.example.com/é/{slug}", "not_encoded"},
{"https://journal.example.com/{lang}", "missing_slug"},
{"https://example.com/" + strings.Repeat("a", 494) + "{slug}", "invalid_pattern"},
{"ftp://x.example.com/{language} here", "invalid_scheme"},
{"https://x.example.com/{language} here", "unknown_placeholder"},
{"https://x.example.com/here there", "not_encoded"},
}
for _, c := range refusals {
t.Run("Patterns that are refused/"+c.code+" "+truncate(c.value), func(t *testing.T) {
e := newPatternEnv(t)
stored := e.sites.sites[siteMainID]
stored.PublicURLPattern = localeOf(journalPattern)
e.sites.sites[siteMainID] = stored
rec := e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(c.value))
expectSiteStatusAndBody(t, rec, http.StatusUnprocessableEntity, c.code)
if !sameText(e.sites.sites[siteMainID].PublicURLPattern, localeOf(journalPattern)) {
t.Errorf("the stored pattern changed")
}
})
}
t.Run("A pattern of 513 characters", func(t *testing.T) {
long := "https://example.com/" + strings.Repeat("a", 513-len("https://example.com/")-len("{slug}")) + "{slug}"
if len(long) != 513 {
t.Fatalf("fixture length %d", len(long))
}
e := newPatternEnv(t)
expectSiteStatusAndBody(t, e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(long)), http.StatusUnprocessableEntity, "invalid_pattern")
})
t.Run("A pattern of 512 characters is accepted", func(t *testing.T) {
exact := "https://example.com/" + strings.Repeat("a", 512-len("https://example.com/")-len("{slug}")) + "{slug}"
e := newPatternEnv(t)
if rec := e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(exact)); rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body)
}
})
t.Run("Only an owner can write", func(t *testing.T) {
cases := []struct {
name string
user uint32
status int
}{
{"an anonymous client", 0, http.StatusUnauthorized},
{"a reader", readerUserID, http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newPatternEnv(t)
rec := e.as(t, c.user, http.MethodPatch, "/v1/site/1", patternBody(journalPattern))
if rec.Code != c.status || e.sites.sites[siteMainID].PublicURLPattern != nil {
t.Errorf("status %d, stored %v", rec.Code, e.sites.sites[siteMainID].PublicURLPattern)
}
})
}
})
t.Run("Saving the pattern changes nothing else", func(t *testing.T) {
e := newPatternEnv(t)
before := e.sites.sites[siteMainID]
e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(journalPattern))
after := e.sites.sites[siteMainID]
after.PublicURLPattern = nil
if after != before {
t.Errorf("site changed: %+v vs %+v", after, before)
}
})
t.Run("Existing site behaviour is unchanged", func(t *testing.T) {
e := newPatternEnv(t)
stored := e.sites.sites[siteMainID]
stored.PublicURLPattern = localeOf(journalPattern)
e.sites.sites[siteMainID] = stored
rec := e.owner(t, http.MethodPatch, "/v1/site/1", `{"name":"Renamed"}`)
if rec.Code != http.StatusOK || !sameText(e.sites.sites[siteMainID].PublicURLPattern, localeOf(journalPattern)) {
t.Errorf("status %d, pattern %v", rec.Code, e.sites.sites[siteMainID].PublicURLPattern)
}
})
}
func truncate(value string) string {
if len(value) > 40 {
return value[:40]
}
return value
}
func TestPublicURLPatternNormalization(t *testing.T) {
t.Run("a nil pattern stays unset", func(t *testing.T) {
if normalized, err := site.NormalizePublicURLPattern(nil); normalized != nil || err != nil {
t.Errorf("got %v, %v", normalized, err)
}
})
t.Run("a pattern without a host is refused as a host error", func(t *testing.T) {
if _, err := site.NormalizePublicURLPattern(localeOf("https:///{slug}")); err != site.ErrPublicURLHost {
t.Errorf("err = %v", err)
}
})
t.Run("percent encoded sequences are accepted and a stray percent is not", func(t *testing.T) {
if _, err := site.NormalizePublicURLPattern(localeOf("https://a.example.com/%C3%A9/{slug}")); err != nil {
t.Errorf("encoded: %v", err)
}
if _, err := site.NormalizePublicURLPattern(localeOf("https://a.example.com/100%/{slug}")); err != site.ErrPublicURLNotEncoded {
t.Errorf("stray: %v", err)
}
})
t.Run("a pattern ending in a percent sequence is judged without panicking", func(t *testing.T) {
cases := []struct {
name string
pattern string
want error
}{
{"a lone percent at the end", "https://a.example.com/{slug}%", site.ErrPublicURLNotEncoded},
{"a percent and one digit at the end", "https://a.example.com/{slug}%4", site.ErrPublicURLNotEncoded},
{"a percent and a non hex digit at the end", "https://a.example.com/{slug}%4G", site.ErrPublicURLNotEncoded},
{"a valid sequence at the end", "https://a.example.com/{slug}%41", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := site.NormalizePublicURLPattern(localeOf(c.pattern)); err != c.want {
t.Errorf("err = %v, want %v", err, c.want)
}
})
}
})
}
+26
View File
@@ -4,11 +4,13 @@ import (
"RenewCMS/api" "RenewCMS/api"
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"io" "io"
"log" "log"
"net/http/httptest" "net/http/httptest"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -27,6 +29,30 @@ type mockSiteRepo struct {
deleteCalls int deleteCalls int
deleteReassignments []*uint32 deleteReassignments []*uint32
setDefaultCalls int setDefaultCalls int
lockedSites []uint32
tryLocked [][]uint32
busy bool
afterTryLock func()
}
func (m *mockSiteRepo) LockSite(id uint32) error {
m.lockedSites = append(m.lockedSites, id)
if m.busy {
return transaction.ErrLockContention
}
return nil
}
func (m *mockSiteRepo) TryLockSites(ids []uint32) error {
m.tryLocked = append(m.tryLocked, slices.Clone(ids))
if m.busy {
return transaction.ErrLockContention
}
if hook := m.afterTryLock; hook != nil {
m.afterTryLock = nil
hook()
}
return nil
} }
func newMockSiteRepo(articles *mockArticleRepo) *mockSiteRepo { func newMockSiteRepo(articles *mockArticleRepo) *mockSiteRepo {
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleInput from '@/components/localization/LocaleInput.vue'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import type { SaveFailure } from '@/composables/useSiteLanguagesPage'
import { baseLanguage, derivedSocialLocale, type SiteLanguages } from '@/lib/siteLanguages'
const props = defineProps<{
languages: SiteLanguages
siteName: string
pending: boolean
failure: SaveFailure | null
nameOf: (tag: string) => string
}>()
const emit = defineEmits<{ close: []; confirm: [tag: string]; reload: [] }>()
const selected = ref<string | undefined>(undefined)
const accepted = ref(false)
const items = computed(() =>
props.languages.available.map((option) => {
const listed = props.languages.languages.find((row) => row.lang === option.lang)
const disabled = listed !== undefined && !listed.enabled
return {
value: option.lang,
label: disabled
? t('{0} · Disabled — re-enable instead', [props.nameOf(option.lang)])
: props.nameOf(option.lang),
disabled,
}
}),
)
const option = computed(() =>
props.languages.available.find((candidate) => candidate.lang === selected.value),
)
const substitute = computed(() => option.value?.substitute ?? null)
const target = computed(() =>
substitute.value && accepted.value ? substitute.value : selected.value,
)
const duplicate = computed(() =>
props.languages.languages.some((row) => row.lang === target.value && row.enabled),
)
const duplicateError = computed(() =>
duplicate.value && target.value
? t('{0} is already enabled for {1}.', [props.nameOf(target.value), props.siteName])
: undefined,
)
const socialLocale = computed(() => {
const derived = selected.value ? derivedSocialLocale(selected.value) : null
return derived ? t('Derived: {0}', [derived]) : t('Not set')
})
const broaderWarning = computed(() => {
if (!substitute.value || !selected.value) {
return null
}
return {
title: t("{0} isn't available", [props.nameOf(selected.value)]),
description: t(
"Only the broader language {0} ({1}) is supported. Pages and the API will say {1}, not {2}. Articles written in this language can't be told apart from other {0}.",
[props.nameOf(baseLanguage(substitute.value)), substitute.value, selected.value],
),
}
})
const needsAcceptance = computed(() => substitute.value !== null && !accepted.value)
const confirmDisabled = computed(() => !selected.value || duplicate.value || needsAcceptance.value)
const longDescription = computed(
() => props.failure === null && !duplicate.value && substitute.value === null,
)
const confirmLabel = computed(() => {
if (props.failure === 'stale') {
return t('Reload')
}
if (props.failure) {
return t('Try again')
}
return substitute.value
? t('Add {0}', [props.nameOf(baseLanguage(substitute.value))])
: t('Add language')
})
const error = computed(() => {
if (props.failure === 'stale') {
return {
title: t('Languages changed. Reload.'),
description: t("Your change wasn't applied."),
}
}
return props.failure
? {
title: t("Couldn't save languages"),
description: t('Nothing changed. Your selection is kept. Try again.'),
}
: null
})
function select(value: string | undefined) {
selected.value = value
accepted.value = false
}
function confirm() {
if (props.failure === 'stale') {
emit('reload')
} else if (target.value) {
emit('confirm', target.value)
}
}
</script>
<template>
<LocaleDialog
:title="t('Add a language')"
:description="
longDescription
? t(
'Choose a language for {0}. Regional variants such as English (United Kingdom) are separate entries.',
[siteName],
)
: t('Choose a language for {0}.', [siteName])
"
:confirm-label="confirmLabel"
:confirm-disabled="failure === 'stale' ? false : confirmDisabled"
:pending="pending"
:error="error"
@close="emit('close')"
@confirm="confirm"
>
<UFormField
:label="t('Language')"
:error="duplicateError"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
error: 'mt-1.5 flex gap-1.5 text-xs/4.5 text-error',
}"
>
<LocaleSelect
:model-value="selected"
:items="items"
:placeholder="t('Choose a language')"
:aria-label="t('Language')"
:invalid="duplicate"
@update:model-value="select"
/>
<template v-if="duplicateError" #error>
<UIcon name="i-lucide-circle-x" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ duplicateError }}</span>
</template>
</UFormField>
<LocaleAlert
v-if="broaderWarning"
tone="warning"
icon="i-lucide-triangle-alert"
:title="broaderWarning.title"
:description="broaderWarning.description"
/>
<UCheckbox
v-if="substitute"
v-model="accepted"
:label="t('Add {0} ({1}) instead', [nameOf(baseLanguage(substitute)), substitute])"
:ui="{
root: 'min-h-6 items-center max-md:min-h-11',
label: 'font-medium text-highlighted',
}"
/>
<template v-if="selected && !substitute && !duplicate">
<UFormField
:label="t('Language code')"
:help="t('Used in the API and in page language metadata.')"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-muted',
}"
>
<LocaleInput
:model-value="selected"
mono
readonly
locked
:aria-label="t('Language code')"
/>
</UFormField>
<UFormField
:label="t('Social locale')"
:help="
t(
'Taken from the language code. For a code without a region, such as fr, an owner sets one or leaves it empty and og:locale is left out.',
)
"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-muted',
}"
>
<LocaleInput
:model-value="socialLocale"
mono
readonly
locked
:aria-label="t('Social locale')"
/>
</UFormField>
</template>
</LocaleDialog>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
import type { SaveFailure } from '@/composables/useSiteLanguagesPage'
import {
disableDescription,
disableWarning,
reenableDescription,
removeBlockedDescription,
usedByLine,
} from '@/lib/languageCopy'
import type { SiteLanguage } from '@/lib/siteLanguages'
const props = defineProps<{
kind: 'default' | 'disable' | 'reenable' | 'remove' | 'removeBlocked'
language: SiteLanguage
name: string
pending: boolean
failure: SaveFailure | null
}>()
const emit = defineEmits<{ close: []; confirm: []; reload: [] }>()
const noContentChanges = computed(() =>
t(
"Existing versions keep their language, status and links. Public requests that name no language will resolve {0} where a published {0} version exists, and return not found where one doesn't. Requests that name a language aren't affected.",
[props.name],
),
)
const stale = computed(() => props.failure === 'stale')
const failed = computed(() => props.failure !== null)
const title = computed(() => {
switch (props.kind) {
case 'default':
return t('Make {0} the default language?', [props.name])
case 'disable':
return t('Disable {0}?', [props.name])
case 'reenable':
return t('Re-enable {0}?', [props.name])
case 'remove':
return t('Remove {0}?', [props.name])
default:
return t("{0} can't be removed", [props.name])
}
})
const description = computed(() => {
const name = props.name
switch (props.kind) {
case 'default':
return failed.value
? t('New articles start in the default language.')
: t(
"New articles start in the default language, and it is the first choice for each article's title in the list.",
)
case 'disable':
return stale.value
? usedByLine(name, props.language)
: disableDescription(name, props.language)
case 'reenable':
return failed.value
? t('Writers can create {0} versions again.', [name])
: reenableDescription(name, props.language)
case 'remove':
return failed.value
? t('No article uses {0}.', [name])
: t('No article uses {0}. You can add it again later.', [name])
default:
return removeBlockedDescription(name, props.language)
}
})
const error = computed(() => {
if (stale.value) {
return {
title: t('Languages changed. Reload.'),
description: t("Your change wasn't applied."),
}
}
if (!failed.value) {
return null
}
const name = props.name
switch (props.kind) {
case 'default':
return {
title: t("Couldn't change the default language"),
description: t('Nothing changed. Try again.'),
}
case 'reenable':
return {
title: t("Couldn't re-enable {0}", [name]),
description: t('Nothing changed. {0} is still disabled. Try again.', [name]),
}
case 'remove':
return {
title: t("Couldn't remove {0}", [name]),
description: t('Nothing changed. {0} is still listed. Try again.', [name]),
}
default:
return {
title: t("Couldn't save languages"),
description: t('Nothing changed. Try again.'),
}
}
})
const confirmLabel = computed(() => {
if (stale.value) {
return t('Reload')
}
if (failed.value) {
return t('Try again')
}
switch (props.kind) {
case 'default':
return t('Make {0} default', [props.name])
case 'disable':
case 'removeBlocked':
return t('Disable {0}', [props.name])
case 'reenable':
return t('Re-enable {0}', [props.name])
default:
return t('Remove {0}', [props.name])
}
})
function confirm() {
if (stale.value) {
emit('reload')
} else {
emit('confirm')
}
}
</script>
<template>
<LocaleDialog
:title="title"
:description="description"
:confirm-label="confirmLabel"
:cancel-label="kind === 'removeBlocked' ? t('Close') : undefined"
:tone="kind === 'remove' ? 'danger' : 'primary'"
:pending="pending"
:error="error"
@close="emit('close')"
@confirm="confirm"
>
<LocaleAlert
v-if="kind === 'default' && !failed"
tone="info"
icon="i-lucide-info"
:title="t('No content changes')"
:description="noContentChanges"
/>
<LocaleAlert
v-else-if="kind === 'disable' && !failed && language.version_count > 0"
tone="warning"
icon="i-lucide-triangle-alert"
:title="t('Nothing is deleted or unpublished')"
:description="disableWarning(name, language)"
/>
</LocaleDialog>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { t } from '@/i18n'
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import { socialLocaleLabel, versionsLine } from '@/lib/languageCopy'
import type { SiteLanguage } from '@/lib/siteLanguages'
defineProps<{
languages: SiteLanguage[]
owner: boolean
nameOf: (tag: string) => string
}>()
const emit = defineEmits<{
editSocial: [lang: string]
reenable: [lang: string]
makeDefault: [lang: string]
disable: [lang: string]
remove: [lang: string]
}>()
function menuItems(language: SiteLanguage) {
const lang = language.lang
if (!language.enabled) {
return [
[
{
label: t('Remove'),
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('remove', lang),
},
],
]
}
return [
[
...(language.is_default
? []
: [
{
label: t('Make default'),
icon: 'i-lucide-star',
onSelect: () => emit('makeDefault', lang),
},
]),
{
label: t('Disable'),
icon: 'i-lucide-eye-off',
onSelect: () => emit('disable', lang),
},
],
[
{
label: t('Remove'),
icon: 'i-lucide-trash-2',
color: 'error' as const,
disabled: language.is_default,
onSelect: () => emit('remove', lang),
},
],
]
}
</script>
<template>
<div class="border border-default bg-default" role="group" :aria-label="t('Languages')">
<div class="bg-elevated px-4 py-2 font-medium text-muted">{{ t('Language') }}</div>
<div
v-for="language in languages"
:key="language.lang"
class="flex min-h-12 flex-wrap items-center gap-3 border-t border-default px-4 py-2.5 max-md:min-h-14"
:class="language.enabled ? '' : 'opacity-85'"
:data-testid="`language-row-${language.lang}`"
>
<div class="flex min-w-0 grow shrink basis-40 flex-col">
<span class="truncate font-medium text-highlighted">{{
nameOf(language.lang)
}}</span>
<span class="font-mono text-xs/5 text-muted">{{ language.lang }}</span>
</div>
<LocaleBadge v-if="language.is_default" tone="info" icon="i-lucide-star">
{{ t('Default') }}
</LocaleBadge>
<LocaleBadge v-else-if="!language.enabled" tone="neutral" icon="i-lucide-eye-off">
{{ t('Disabled') }}
</LocaleBadge>
<span class="w-42.5 text-muted max-md:hidden">{{ versionsLine(language) }}</span>
<div class="flex items-center gap-1.5 md:w-67.5 md:whitespace-nowrap max-md:basis-full">
<span class="text-xs/5 whitespace-nowrap text-muted">{{ t('Social locale') }}</span>
<span
class="font-mono text-xs"
:class="
language.social_locale_state === 'not_set'
? 'text-muted'
: 'text-highlighted'
"
>{{ socialLocaleLabel(language) }}</span
>
<LocaleButton
v-if="owner"
kind="ghost"
small
:aria-label="t('Edit social locale for {0}', [nameOf(language.lang)])"
@click="emit('editSocial', language.lang)"
>
{{ t('Edit') }}
</LocaleButton>
</div>
<LocaleButton
v-if="owner && !language.enabled"
small
@click="emit('reenable', language.lang)"
>
{{ t('Re-enable') }}
</LocaleButton>
<UDropdownMenu v-if="owner" :items="menuItems(language)" :content="{ align: 'end' }">
<LocaleButton
kind="ghost"
small
icon="i-lucide-ellipsis"
icon-only
:aria-label="t('Actions for {0}', [nameOf(language.lang)])"
/>
</UDropdownMenu>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{
tone: 'info' | 'error' | 'warning' | 'neutral' | 'success'
icon: string
title: string
description?: string
}>()
</script>
<template>
<UAlert
:role="tone === 'error' ? 'alert' : 'status'"
:color="tone"
variant="soft"
:icon="icon"
:title="title"
:description="description"
:close="false"
orientation="vertical"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-3',
icon: 'size-4',
title: 'font-semibold text-highlighted',
description: 'text-pretty text-toned',
actions: 'mt-2.5 gap-2',
}"
>
<template v-if="$slots.actions" #actions>
<slot name="actions" />
</template>
</UAlert>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{
tone: 'neutral' | 'info'
icon: string
}>()
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:icon="icon"
class="border"
:class="
tone === 'info'
? 'border-(--rc-info-soft) bg-(--rc-info-soft) text-info'
: 'border-(--ui-bg-elevated) bg-elevated text-toned'
"
:ui="{
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs whitespace-nowrap',
leadingIcon: 'size-3',
}"
>
<slot />
</UBadge>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
withDefaults(
defineProps<{
kind?: 'primary' | 'secondary' | 'ghost' | 'danger'
small?: boolean
icon?: string
iconOnly?: boolean
ariaLabel?: string
disabled?: boolean
loading?: boolean
block?: boolean
}>(),
{
kind: 'secondary',
small: false,
icon: undefined,
iconOnly: false,
ariaLabel: undefined,
disabled: false,
loading: false,
block: false,
},
)
</script>
<template>
<UButton
:color="kind === 'primary' ? 'primary' : kind === 'danger' ? 'error' : 'neutral'"
:variant="kind === 'secondary' ? 'outline' : kind === 'ghost' ? 'ghost' : 'solid'"
:icon="icon"
:aria-label="ariaLabel"
:disabled="disabled"
:loading="loading"
:block="block"
class="justify-center font-medium whitespace-nowrap max-md:h-11"
:class="[
small ? 'h-7' : 'h-8',
iconOnly ? (small ? 'w-7 px-0 max-md:w-11' : 'w-8 px-0 max-md:w-11') : 'px-3',
kind === 'secondary'
? 'border border-accented bg-default text-highlighted ring-0'
: kind === 'ghost'
? 'text-toned'
: '',
]"
:ui="{ leadingIcon: 'size-4' }"
>
<slot />
</UButton>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
withDefaults(
defineProps<{
title: string
description?: string
confirmLabel: string
cancelLabel?: string
tone?: 'primary' | 'danger'
pending?: boolean
confirmDisabled?: boolean
error?: { title: string; description?: string } | null
}>(),
{
description: undefined,
cancelLabel: undefined,
tone: 'primary',
pending: false,
confirmDisabled: false,
error: null,
},
)
const emit = defineEmits<{ close: []; confirm: [] }>()
const CLOSE_BUTTON = {
color: 'neutral',
variant: 'ghost',
class: 'size-11 justify-center p-0 text-muted md:size-7',
} as const
</script>
<template>
<UModal
:open="true"
:title="title"
:description="description"
:dismissible="!pending"
:close="pending ? false : CLOSE_BUTTON"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-25 sm:max-w-115`,
close: 'end-6 top-8 md:top-5',
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
title: 'pe-14 text-lg/6 font-semibold md:pe-10',
description: 'mt-1 pe-14 text-sm text-pretty text-toned md:pe-10',
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0',
footer: SHEET_FOOTER_CLASS,
}"
@update:open="(value: boolean) => !value && emit('close')"
>
<template #body>
<div class="flex flex-col gap-3.5">
<LocaleAlert
v-if="error"
tone="error"
icon="i-lucide-circle-x"
:title="error.title"
:description="error.description"
/>
<slot />
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 md:flex-row md:justify-end">
<LocaleButton :disabled="pending" block class="md:w-auto" @click="emit('close')">
{{ cancelLabel ?? t('Cancel') }}
</LocaleButton>
<LocaleButton
:kind="tone === 'danger' ? 'danger' : 'primary'"
:disabled="confirmDisabled || pending"
:loading="pending"
block
class="md:w-auto"
@click="emit('confirm')"
>
{{ confirmLabel }}
</LocaleButton>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
mono?: boolean
readonly?: boolean
flat?: boolean
locked?: boolean
invalid?: boolean
placeholder?: string
ariaLabel?: string
maxlength?: number
}>()
const model = defineModel<string>({ default: '' })
</script>
<template>
<UInput
v-model="model"
:readonly="readonly"
:placeholder="placeholder"
:aria-label="ariaLabel"
:aria-invalid="invalid ? 'true' : undefined"
:maxlength="maxlength"
:icon="locked ? 'i-lucide-lock' : undefined"
trailing
class="w-full"
:ui="{
base: [
'h-8 rounded-none border bg-default px-2.5 text-highlighted ring-0 max-md:h-11',
mono ? 'font-mono text-xs' : 'text-sm',
invalid ? 'border-error' : 'border-accented',
readonly && !flat ? 'bg-elevated' : '',
],
trailingIcon: 'size-3.5 text-muted',
}"
/>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
defineProps<{
items: { label: string; value: string; disabled?: boolean }[]
placeholder?: string
ariaLabel?: string
invalid?: boolean
readonly?: boolean
hasValue?: boolean
}>()
const model = defineModel<string | undefined>()
</script>
<template>
<USelect
v-model="model"
:items="items"
value-key="value"
:placeholder="placeholder"
:aria-label="ariaLabel"
:aria-invalid="invalid ? 'true' : undefined"
:disabled="readonly"
trailing-icon="i-lucide-chevrons-up-down"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: [
'h-8 rounded-none border bg-default px-2.5 text-sm ring-0 max-md:h-11',
invalid ? 'border-error' : 'border-accented',
readonly ? 'bg-elevated' : '',
],
trailingIcon: 'size-3.5 text-muted',
placeholder: 'text-muted',
value: 'text-highlighted',
}"
/>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, 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 type { SaveFailure } from '@/composables/useSiteLanguagesPage'
import {
derivedSocialLocale,
isSocialLocaleInput,
socialLocaleExample,
type SiteLanguage,
} from '@/lib/siteLanguages'
const props = defineProps<{
language: SiteLanguage
name: string
pending: boolean
failure: SaveFailure | null
}>()
const emit = defineEmits<{ close: []; confirm: [value: string | null]; reload: [] }>()
const value = ref(props.language.social_locale ?? '')
const example = computed(() => socialLocaleExample(props.language.lang))
const derived = computed(() => derivedSocialLocale(props.language.lang))
const typedInvalid = computed(
() => value.value !== '' && !isSocialLocaleInput(value.value, props.language.lang),
)
const invalid = computed(() => typedInvalid.value || props.failure === 'invalidSocialLocale')
const fieldError = computed(() =>
invalid.value
? t('Use letters and one underscore, for example {0}.', [example.value])
: undefined,
)
const error = computed(() => {
if (props.failure === 'stale') {
return {
title: t('Languages changed. Reload.'),
description: t("Your change wasn't applied."),
}
}
return props.failure === 'failed'
? {
title: t("Couldn't save languages"),
description: t('Nothing changed. Your selection is kept. Try again.'),
}
: null
})
function confirm() {
if (props.failure === 'stale') {
emit('reload')
return
}
emit('confirm', value.value === '' ? null : value.value)
}
</script>
<template>
<LocaleDialog
:title="t('Social locale for {0}', [name])"
:description="t('Used for og:locale on {0} pages. Leave it empty to omit the tag.', [name])"
:confirm-label="
failure === 'stale'
? t('Reload')
: failure === 'failed'
? t('Try again')
: t('Save social locale')
"
:confirm-disabled="failure === 'stale' ? false : typedInvalid"
:pending="pending"
:error="error"
@close="emit('close')"
@confirm="confirm"
>
<UFormField
:label="t('Social locale')"
:error="fieldError"
:help="
t('Format: language_REGION, for example {0}. Letters only, one underscore.', [
example,
])
"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-muted',
error: 'mt-1.5 flex gap-1.5 text-xs/4.5 text-error',
}"
>
<LocaleInput v-model="value" mono :invalid="invalid" :aria-label="t('Social locale')" />
<template v-if="fieldError" #error>
<UIcon name="i-lucide-circle-x" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ fieldError }}</span>
</template>
</UFormField>
<LocaleAlert
v-if="language.social_locale_state === 'derived' && derived"
tone="info"
icon="i-lucide-info"
:title="t('Derived from the language code')"
:description="
t('{0} gives {1}. Setting a value replaces the derived one.', [
language.lang,
derived,
])
"
/>
</LocaleDialog>
</template>
+1
View File
@@ -11,6 +11,7 @@ export interface Article extends Partial<ArticleSeo> {
title: string title: string
body: string body: string
is_online: boolean is_online: boolean
lang?: string
site_id?: number site_id?: number
created_by?: number created_by?: number
authors?: Byline[] authors?: Byline[]
+134
View File
@@ -0,0 +1,134 @@
import { computed, ref, type Ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import {
useSiteLanguages,
type LanguageSetup,
type SetupSite,
} from '@/composables/useSiteLanguages'
import { useSites } from '@/composables/useSites'
export type SetupLoadState = 'loading' | 'ready' | 'error'
export type SetupStage = 'editing' | 'applying' | 'inProgress' | 'done' | 'failed'
export function useLanguageSetupPage(key: Ref<string>) {
const { getSetup, applySetup } = useSiteLanguages()
const { listAllSites } = useSites()
const loadState = ref<SetupLoadState>('loading')
const setup = ref<LanguageSetup | null>(null)
const choices = ref<Record<string, string | undefined>>({})
const stage = ref<SetupStage>('editing')
const assigned = ref<number | null>(null)
const knownSiteName = ref<string | null>(null)
const checking = ref(false)
const sites = computed<SetupSite[]>(() => setup.value?.sites ?? [])
const nothingPending = computed(() => loadState.value === 'ready' && sites.value.length === 0)
const everyRowChosen = computed(() =>
sites.value.every((site) => (choices.value[site.key] ?? '') !== ''),
)
const articleTotal = computed(() =>
sites.value.reduce((total, site) => total + site.article_count, 0),
)
const onlyUnseeded = computed(
() => sites.value.length > 0 && sites.value.every((site) => site.suggestion === null),
)
const singleEmptySite = computed(
() => sites.value.length === 1 && sites.value[0]!.article_count === 0,
)
const locked = computed(
() => stage.value === 'done' || stage.value === 'inProgress' || stage.value === 'applying',
)
function seed(next: LanguageSetup) {
const seeded: Record<string, string | undefined> = {}
for (const site of next.sites) {
seeded[site.key] = choices.value[site.key] ?? site.suggestion?.lang
}
choices.value = seeded
}
async function loadSiteName() {
try {
const all = await listAllSites()
knownSiteName.value = all.find((site) => site.key === key.value)?.name ?? null
} catch {
knownSiteName.value = null
}
}
async function load() {
loadState.value = 'loading'
stage.value = 'editing'
void loadSiteName()
try {
const next = await getSetup()
setup.value = next
seed(next)
loadState.value = 'ready'
if (next.sites.length === 0) {
stage.value = 'done'
}
} catch {
loadState.value = 'error'
}
}
async function checkAgain() {
checking.value = true
try {
const next = await getSetup()
setup.value = next
seed(next)
stage.value = next.sites.length === 0 ? 'done' : 'inProgress'
} catch {
stage.value = 'inProgress'
} finally {
checking.value = false
}
}
function choose(siteKey: string, lang: string | undefined) {
choices.value = { ...choices.value, [siteKey]: lang }
}
async function apply() {
if (!everyRowChosen.value || locked.value) {
return
}
stage.value = 'applying'
try {
const result = await applySetup(
sites.value.map((site) => ({ key: site.key, lang: choices.value[site.key]! })),
)
assigned.value = result.assigned
stage.value = 'done'
} catch (error) {
stage.value =
error instanceof ApiError && error.status === 409 && error.body === 'setup_in_progress'
? 'inProgress'
: 'failed'
}
}
return {
loadState,
setup,
sites,
choices,
stage,
assigned,
knownSiteName,
checking,
nothingPending,
everyRowChosen,
articleTotal,
onlyUnseeded,
singleEmptySite,
locked,
load,
checkAgain,
choose,
apply,
}
}
+185
View File
@@ -0,0 +1,185 @@
import { computed, ref, type Ref } from 'vue'
import { t, locale } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useArticles } from '@/composables/useArticles'
import { useSiteLanguages } from '@/composables/useSiteLanguages'
import { useSites, type Site } from '@/composables/useSites'
import {
PUBLIC_URL_PATTERN_MAX_LENGTH,
checkPublicUrlPattern,
previewPublicUrl,
unknownPlaceholderOf,
type PublicUrlPatternError,
} from '@/lib/publicUrlPattern'
import { languageName } from '@/lib/siteLanguages'
export type PublicUrlLoadState = 'loading' | 'ready' | 'error'
interface PreviewSource {
title: string
slug: string
lang: string
}
const SERVER_CODES: readonly PublicUrlPatternError[] = [
'invalid_scheme',
'invalid_host',
'unknown_placeholder',
'not_encoded',
'missing_slug',
]
export function usePublicUrlPage(key: Ref<string>) {
const { listAllSites, updateSite } = useSites()
const { listArticles } = useArticles()
const { getLanguages } = useSiteLanguages()
const toast = useLocalizedToast()
const loadState = ref<PublicUrlLoadState>('loading')
const site = ref<Site | null>(null)
const value = ref('')
const preview = ref<PreviewSource | null>(null)
const serverError = ref<PublicUrlPatternError | null>(null)
const saving = ref(false)
const saveFailed = ref(false)
const saved = computed(() => site.value?.public_url_pattern ?? null)
const clientError = computed(() => checkPublicUrlPattern(value.value))
const errorCode = computed(() => clientError.value ?? serverError.value)
const unset = computed(() => saved.value === null)
const empty = computed(() => value.value.trim() === '')
const canSave = computed(
() => errorCode.value === null && !(empty.value && unset.value) && !saving.value,
)
const errorMessage = computed(() => {
switch (errorCode.value) {
case 'invalid_scheme':
case 'invalid_host':
return t('Start with https:// or http://.')
case 'unknown_placeholder': {
const token = unknownPlaceholderOf(value.value)
return token
? t('Unknown placeholder {0}. Use {lang} and {slug}.', [token])
: t('Unknown placeholder. Use {lang} and {slug}.')
}
case 'not_encoded':
return t(
'Remove spaces and characters that need encoding. Use letters, digits, - and _.',
)
case 'missing_slug':
return t('Add {slug} so each article has its own address.')
default:
return undefined
}
})
const previewUrl = computed(() =>
preview.value && errorCode.value === null && !empty.value
? previewPublicUrl(value.value, preview.value.lang, preview.value.slug)
: null,
)
const previewLabel = computed(() =>
preview.value
? t('Preview with "{0}" in {1}', [
preview.value.title,
languageName(preview.value.lang, locale.value),
])
: '',
)
async function loadPreview(siteKey: string) {
try {
const page = await listArticles({ page: 1, size: 1, site: siteKey })
const article = page.items[0]
if (!article) {
preview.value = null
return
}
const lang = article.lang || (await getLanguages(siteKey)).default
preview.value = lang ? { title: article.title, slug: article.slug ?? '', lang } : null
} catch {
preview.value = null
}
}
async function load() {
loadState.value = 'loading'
try {
const sites = await listAllSites()
const found = sites.find((candidate) => candidate.key === key.value)
if (!found) {
loadState.value = 'error'
return
}
site.value = found
value.value = found.public_url_pattern ?? ''
serverError.value = null
loadState.value = 'ready'
await loadPreview(found.key)
} catch {
loadState.value = 'error'
}
}
function edit(next: string) {
value.value = next.slice(0, PUBLIC_URL_PATTERN_MAX_LENGTH)
serverError.value = null
saveFailed.value = false
}
function cancel() {
value.value = saved.value ?? ''
serverError.value = null
saveFailed.value = false
}
async function save() {
if (!site.value || !canSave.value) {
return
}
saving.value = true
saveFailed.value = false
try {
const updated = await updateSite(site.value.id, {
public_url_pattern: empty.value ? null : value.value.trim(),
})
site.value = { ...site.value, ...updated }
value.value = updated.public_url_pattern ?? ''
toast.add(() => ({
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: t('Public URL saved'),
description: t('{0} pages now use this pattern.', [site.value?.name ?? key.value]),
}))
} catch (error) {
const code = SERVER_CODES.find(
(candidate) =>
error instanceof ApiError && error.status === 422 && error.body === candidate,
)
if (code) {
serverError.value = code
} else {
saveFailed.value = true
}
} finally {
saving.value = false
}
}
return {
loadState,
site,
value,
saving,
saveFailed,
unset,
canSave,
errorMessage,
previewUrl,
previewLabel,
load,
edit,
cancel,
save,
}
}
+57
View File
@@ -0,0 +1,57 @@
import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest'
import type { AvailableLanguage, LanguagesUpdate, SiteLanguages } from '@/lib/siteLanguages'
export interface SetupSuggestion {
lang: string
substituted: boolean
}
export interface SetupSite {
key: string
name: string
locale: string | null
article_count: number
suggestion: SetupSuggestion | null
}
export interface LanguageSetup {
sites: SetupSite[]
available: AvailableLanguage[]
}
export interface SetupChoice {
key: string
lang: string
}
export interface SetupResult {
assigned: number
skipped: string[]
sites: { key: string; lang: string; assigned: number }[]
}
export function useSiteLanguages() {
const { request } = useApi()
function getLanguages(key: string): Promise<SiteLanguages> {
return request<SiteLanguages>(`/v1/site/${encodeURIComponent(key)}/languages`)
}
function putLanguages(key: string, body: LanguagesUpdate): Promise<SiteLanguages> {
return request<SiteLanguages>(
`/v1/site/${encodeURIComponent(key)}/languages`,
jsonInit('PUT', body),
)
}
function getSetup(): Promise<LanguageSetup> {
return request<LanguageSetup>('/v1/language/setup')
}
function applySetup(sites: SetupChoice[]): Promise<SetupResult> {
return request<SetupResult>('/v1/language/setup', jsonInit('POST', { sites }))
}
return { getLanguages, putLanguages, getSetup, applySetup }
}
+295
View File
@@ -0,0 +1,295 @@
import { computed, ref, type Ref } from 'vue'
import { t, locale } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useSiteLanguages } from '@/composables/useSiteLanguages'
import { useSites } from '@/composables/useSites'
import {
languageName,
languagesUpdate,
withDefault,
withEnabled,
withLanguage,
withoutLanguage,
withSocialLocale,
type EditedLanguages,
type SiteLanguage,
type SiteLanguages,
} from '@/lib/siteLanguages'
export type LoadState = 'loading' | 'ready' | 'error'
export type LanguageDialog =
| { kind: 'add' }
| { kind: 'default'; lang: string }
| { kind: 'disable'; lang: string }
| { kind: 'reenable'; lang: string }
| { kind: 'remove'; lang: string }
| { kind: 'removeBlocked'; lang: string }
| { kind: 'social'; lang: string }
export type LanguageBanner =
| { kind: 'disableDefault'; lang: string }
| { kind: 'disableLast' }
| { kind: 'stale' }
| { kind: 'failed'; action: 'default' | 'remove'; lang: string }
export type SaveFailure = 'failed' | 'stale' | 'invalidSocialLocale'
type SaveOutcome = null | SaveFailure | 'inUse' | 'setupRequired'
export function useSiteLanguagesPage(key: Ref<string>) {
const { getLanguages, putLanguages } = useSiteLanguages()
const { listAllSites } = useSites()
const toast = useLocalizedToast()
const loadState = ref<LoadState>('loading')
const data = ref<SiteLanguages | null>(null)
const dialog = ref<LanguageDialog | null>(null)
const banner = ref<LanguageBanner | null>(null)
const failure = ref<SaveFailure | null>(null)
const saving = ref(false)
const siteName = ref<string | null>(null)
const enabledCount = computed(
() => data.value?.languages.filter((language) => language.enabled).length ?? 0,
)
function name(tag: string) {
return languageName(tag, locale.value)
}
function languageOf(tag: string): SiteLanguage | undefined {
return data.value?.languages.find((language) => language.lang === tag)
}
async function loadSiteName() {
try {
const sites = await listAllSites()
siteName.value = sites.find((site) => site.key === key.value)?.name ?? null
} catch {
siteName.value = null
}
}
async function load() {
loadState.value = 'loading'
void loadSiteName()
try {
data.value = await getLanguages(key.value)
loadState.value = 'ready'
} catch {
loadState.value = 'error'
}
}
async function refresh() {
try {
data.value = await getLanguages(key.value)
} catch {
return
}
}
async function reload() {
dialog.value = null
banner.value = null
failure.value = null
await load()
}
function closeDialog() {
if (saving.value) {
return
}
dialog.value = null
failure.value = null
}
function openDialog(next: LanguageDialog) {
failure.value = null
banner.value = null
dialog.value = next
}
function openDisable(lang: string) {
if (enabledCount.value <= 1) {
banner.value = { kind: 'disableLast' }
return
}
if (languageOf(lang)?.is_default) {
banner.value = { kind: 'disableDefault', lang }
return
}
openDialog({ kind: 'disable', lang })
}
function disableInstead(lang: string) {
if (languageOf(lang)?.enabled) {
openDisable(lang)
} else {
closeDialog()
}
}
function openRemove(lang: string) {
const language = languageOf(lang)
openDialog({ kind: (language?.version_count ?? 0) > 0 ? 'removeBlocked' : 'remove', lang })
}
function classify(error: unknown): SaveOutcome {
if (!(error instanceof ApiError)) {
return 'failed'
}
if (error.status === 409 && error.body === 'languages_changed') {
return 'stale'
}
if (error.status === 409 && error.body === 'language_in_use') {
return 'inUse'
}
if (error.status === 409 && error.body === 'setup_required') {
return 'setupRequired'
}
if (error.status === 422 && error.body === 'invalid_social_locale') {
return 'invalidSocialLocale'
}
return 'failed'
}
async function save(change: EditedLanguages): Promise<SaveOutcome> {
saving.value = true
failure.value = null
try {
data.value = await putLanguages(key.value, languagesUpdate(change))
banner.value = null
return null
} catch (error) {
const outcome = classify(error)
if (outcome === 'stale') {
banner.value = { kind: 'stale' }
}
if (outcome === 'inUse' || outcome === 'setupRequired') {
await refresh()
} else {
failure.value = outcome
}
return outcome
} finally {
saving.value = false
}
}
async function addLanguage(tag: string) {
if (!data.value) {
return
}
const outcome = await save(withLanguage(data.value, tag))
if (outcome === null) {
dialog.value = null
toast.add(() => ({
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: t('{0} added', [name(tag)]),
description: t('Writers can now create {0} versions.', [name(tag)]),
}))
}
}
async function makeDefault(lang: string) {
if (!data.value) {
return
}
const outcome = await save(withDefault(data.value, lang))
if (outcome === null) {
dialog.value = null
} else if (outcome === 'failed') {
banner.value = { kind: 'failed', action: 'default', lang }
}
}
async function disable(lang: string) {
if (!data.value) {
return
}
const outcome = await save(withEnabled(data.value, lang, false))
if (outcome === null) {
dialog.value = null
}
}
async function reenable(lang: string) {
if (!data.value) {
return
}
const outcome = await save(withEnabled(data.value, lang, true))
if (outcome === null) {
dialog.value = null
toast.add(() => ({
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: t('{0} re-enabled', [name(lang)]),
description: t('Writers can create {0} versions again. Drafts can be published.', [
name(lang),
]),
}))
}
}
async function remove(lang: string) {
if (!data.value) {
return
}
const outcome = await save(withoutLanguage(data.value, lang))
if (outcome === null) {
dialog.value = null
} else if (outcome === 'inUse') {
dialog.value = { kind: 'removeBlocked', lang }
} else if (outcome === 'failed') {
banner.value = { kind: 'failed', action: 'remove', lang }
}
}
async function saveSocialLocale(lang: string, value: string | null) {
if (!data.value) {
return
}
const outcome = await save(withSocialLocale(data.value, lang, value))
if (outcome === null) {
dialog.value = null
}
}
function retryBanner() {
const current = banner.value
if (current?.kind !== 'failed') {
return
}
return current.action === 'default' ? makeDefault(current.lang) : remove(current.lang)
}
return {
loadState,
data,
dialog,
banner,
failure,
saving,
siteName,
name,
languageOf,
load,
reload,
closeDialog,
openDialog,
openDisable,
disableInstead,
openRemove,
addLanguage,
makeDefault,
disable,
reenable,
remove,
saveSocialLocale,
retryBanner,
}
}
+2
View File
@@ -18,6 +18,7 @@ export interface Site {
default_description?: string default_description?: string
is_default: boolean is_default: boolean
article_count: number article_count: number
public_url_pattern?: string | null
} }
export interface NewSite { export interface NewSite {
@@ -42,6 +43,7 @@ export interface SitePatch {
site_name?: string site_name?: string
title_template?: string title_template?: string
default_description?: string default_description?: string
public_url_pattern?: string | null
} }
export function useSites() { export function useSites() {
+10 -8
View File
@@ -57,7 +57,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'GET /v1/media/purge-preview', 'GET /v1/media/purge-preview',
'POST /v1/media/purge', 'POST /v1/media/purge',
], ],
'Sites and authors': ['GET /v1/site/', 'GET /v1/author/'], 'Sites and authors': ['GET /v1/site/', 'GET /v1/site/{key}/languages', 'GET /v1/author/'],
'Users and invites': [ 'Users and invites': [
'GET /v1/user/', 'GET /v1/user/',
'PATCH /v1/user/me', 'PATCH /v1/user/me',
@@ -91,11 +91,13 @@ const EXPECTED_SESSION: Record<string, string[]> = {
'PATCH /v1/site/{id}', 'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default', 'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}', 'DELETE /v1/site/{id}',
'PUT /v1/site/{key}/languages',
'POST /v1/author/', 'POST /v1/author/',
'PATCH /v1/author/{id}', 'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}', 'DELETE /v1/author/{id}',
'POST /v1/author/me', 'POST /v1/author/me',
], ],
'Language setup · owner only': ['GET /v1/language/setup', 'POST /v1/language/setup'],
'Image variants': [ 'Image variants': [
'GET /v1/image-variants/', 'GET /v1/image-variants/',
'POST /v1/image-variants/', 'POST /v1/image-variants/',
@@ -241,7 +243,7 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
} }
describe('Catalogue routes', () => { describe('Catalogue routes', () => {
it('holds exactly the 42 key-callable routes in 9 groups, in order', () => { it('holds exactly the 43 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',
@@ -258,20 +260,20 @@ 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(42) expect(allEndpoints()).toHaveLength(43)
}) })
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, 2, 6, 1, 8, 2, 5, 5, 2, 11, 3, 6, 1,
]) ])
}) })
it('holds the 37 session-only rows in 6 sub-groups', () => { it('holds the 40 session-only rows in 7 sub-groups', () => {
expect( expect(
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])), Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
).toEqual(EXPECTED_SESSION) ).toEqual(EXPECTED_SESSION)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(37) expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(40)
}) })
it('holds the 6 public auth flow rows', () => { it('holds the 6 public auth flow rows', () => {
@@ -293,7 +295,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 + 42 + 37 + 6) expect(described).toHaveLength(9 + 43 + 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([])
}) })
@@ -860,7 +862,7 @@ describe('Session only rows', () => {
expect(rows.filter((row) => row.answers403).map(label)).toEqual( expect(rows.filter((row) => row.answers403).map(label)).toEqual(
rows.map(label).filter((route) => !excluded.includes(route)), rows.map(label).filter((route) => !excluded.includes(route)),
) )
expect(rows.filter((row) => row.answers403)).toHaveLength(33) expect(rows.filter((row) => row.answers403)).toHaveLength(36)
}) })
it('lists every /v1/author route that is not key-callable, POST /v1/author/me included, as a session row', () => { it('lists every /v1/author route that is not key-callable, POST /v1/author/me included, as a session row', () => {
+45
View File
@@ -1560,6 +1560,43 @@ export const API_GROUPS: ApiGroup[] = [
], ],
}, },
), ),
endpoint(
'site-languages',
'GET',
'/v1/site/{key}/languages',
'List the content languages of a site: the default first, then the other enabled ones, then the disabled ones. Each carries its version and published counts and its social locale. Send the revision back when you change them. A site with no confirmed default answers needs_setup true and no languages.',
'Verified user',
verified([e(401, UNAUTHORIZED), e(404, 'site_not_found')]),
json({
revision: '9f2c4d1e7a',
default: 'en',
needs_setup: false,
languages: [
{
lang: 'en',
enabled: true,
is_default: true,
version_count: 7,
published_count: 6,
social_locale: null,
social_locale_state: 'not_set',
},
{
lang: 'fr',
enabled: true,
is_default: false,
version_count: 4,
published_count: 2,
social_locale: 'fr_FR',
social_locale_state: 'set',
},
],
available: [
{ lang: 'pt', substitute: null },
{ lang: 'pt-PT', substitute: 'pt' },
],
}),
),
endpoint( endpoint(
'author-list', 'author-list',
'GET', 'GET',
@@ -1797,6 +1834,7 @@ export const SESSION_GROUPS: SessionGroup[] = [
row('PATCH', '/v1/site/{id}', 'Edit a site'), row('PATCH', '/v1/site/{id}', 'Edit a site'),
row('PATCH', '/v1/site/{id}/default', 'Make a site the default'), row('PATCH', '/v1/site/{id}/default', 'Make a site the default'),
row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'), row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'),
row('PUT', '/v1/site/{key}/languages', 'Replace the content languages of a site'),
row('POST', '/v1/author/', 'Create an author'), row('POST', '/v1/author/', 'Create an author'),
row( row(
'PATCH', 'PATCH',
@@ -1811,6 +1849,13 @@ export const SESSION_GROUPS: SessionGroup[] = [
row('POST', '/v1/author/me', 'Create your own author profile, as any verified user'), row('POST', '/v1/author/me', 'Create your own author profile, as any verified user'),
], ],
}, },
{
title: 'Language setup · owner only',
rows: [
row('GET', '/v1/language/setup', 'Sites that still need a default language'),
row('POST', '/v1/language/setup', 'Confirm the default language of every pending site'),
],
},
{ {
title: 'Image variants', title: 'Image variants',
rows: [ rows: [
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { localizationMessages } from './localizationMessages'
import { messages } from './messages'
const SOURCES = [
'components/localization/AddLanguageDialog.vue',
'components/localization/LanguageActionDialog.vue',
'components/localization/LanguageRows.vue',
'components/localization/LocaleDialog.vue',
'components/localization/SocialLocaleDialog.vue',
'composables/usePublicUrlPage.ts',
'composables/useSiteLanguagesPage.ts',
'lib/languageCopy.ts',
'views/LanguageSetupView.vue',
'views/PublicUrlView.vue',
'views/SiteLanguagesView.vue',
]
const SAME_IN_FRENCH = new Set(['Setup', 'Disable', 'Sites', '1 article', '{0} articles'])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
const NUMBERED = /\{\d+\}/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Localization messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of the three pages', () => {
expect(keys.length).toBeGreaterThan(120)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has its own French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(key as string)) {
expect(french).not.toBe(key)
}
},
)
it.each(Object.entries(localizationMessages))(
'keeps the placeholders of "%s" in French',
(english, french) => {
expect([...french.matchAll(NUMBERED)].map((m) => m[0]).sort()).toEqual(
[...english.matchAll(NUMBERED)].map((m) => m[0]).sort(),
)
expect(french.includes('{lang}')).toBe(english.includes('{lang}'))
expect(french.includes('{slug}')).toBe(english.includes('{slug}'))
},
)
})
+217
View File
@@ -0,0 +1,217 @@
export const localizationMessages = {
Languages: 'Langues',
'{0} · Languages': '{0} · Langues',
'Back to Sites': 'Retour aux sites',
'Content languages for {0}. Each article version is written in one language.':
'Langues de contenu de {0}. Chaque version d’article est écrite dans une seule langue.',
'Add language': 'Ajouter une langue',
"Couldn't load languages": 'Impossible de charger les langues',
'Only owners can change languages': 'Seuls les propriétaires peuvent modifier les langues',
'You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.':
'Vous pouvez voir quelles langues sont activées, leur locale sociale et le nombre de versions de chacune. Demandez à un propriétaire de les modifier.',
'Set up languages': 'Configurer les langues',
"Existing articles don't have a language yet. Confirm or change the language for each site.":
'Les articles existants n’ont pas encore de langue. Confirmez ou changez la langue de chaque site.',
'{0} is the default': '{0} est la langue par défaut',
'Choose another default first, then disable {0}.':
'Choisissez d’abord une autre langue par défaut, puis désactivez {0}.',
'At least one language must stay enabled': 'Au moins une langue doit rester activée',
'{0} needs a language for new articles.':
'{0} a besoin d’une langue pour les nouveaux articles.',
'Languages changed. Reload.': 'Langues modifiées. Rechargez.',
"Another owner changed the languages for {0} while this page was open. Reload to see the current list. Your change wasn't applied.":
'Un autre propriétaire a modifié les langues de {0} pendant que cette page était ouverte. Rechargez pour voir la liste actuelle. Votre modification n’a pas été appliquée.',
"Couldn't change the default language": 'Impossible de changer la langue par défaut',
"Couldn't remove {0}": 'Impossible de retirer {0}',
'Nothing changed. {0} is still the default. Try again.':
'Rien n’a changé. {0} reste la langue par défaut. Réessayez.',
'Nothing changed. {0} is still listed. Try again.':
'Rien n’a changé. {0} figure toujours dans la liste. Réessayez.',
'{0} uses one language': '{0} utilise une seule langue',
'New articles are written in {0}. Add a language to write translations and publish them separately.':
'Les nouveaux articles sont rédigés en {0}. Ajoutez une langue pour écrire des traductions et les publier séparément.',
'Social locale': 'Locale sociale',
'Regional language kept: {0}': 'Langue régionale conservée : {0}',
'Setup kept the full site locale, so the default content language is {0}, not plain {1}. The site locale still formats dates and numbers. Social locale: {2}, taken from the language code.':
'La configuration a conservé la locale complète du site : la langue de contenu par défaut est donc {0}, et non {1} seul. La locale du site continue de formater les dates et les nombres. Locale sociale : {2}, tirée du code de langue.',
'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out.':
'Désactiver une langue garde ses versions lisibles et modifiables, et les versions publiées restent en ligne. Les brouillons peuvent être publiés de nouveau après la réactivation. Locale sociale : Dérivée vient du code de langue, Définie est choisie par un propriétaire, Non définie omet og:locale.',
'Public URL': 'URL publique',
'{0} · Public URL': '{0} · URL publique',
'Where {0} articles appear on your website. Used for canonical and alternate links.':
'L’adresse où les articles de {0} apparaissent sur votre site web. Utilisée pour les liens canoniques et alternatifs.',
'Only owners can change this': 'Seuls les propriétaires peuvent modifier ceci',
'You can read the pattern. Ask an owner to change it.':
'Vous pouvez lire le modèle. Demandez à un propriétaire de le modifier.',
'Public URL not configured': 'URL publique non configurée',
'Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.':
'Les adresses de page, canoniques et alternatives restent masquées tant que vous n’ajoutez pas de modèle. Les liens par identifiant d’article fonctionnent toujours.',
"Couldn't save languages": 'Impossible d’enregistrer les langues',
'Nothing changed. Your selection is kept. Try again.':
'Rien n’a changé. Votre sélection est conservée. Réessayez.',
'Public URL pattern': 'Modèle d’URL publique',
'Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.':
'Variables : {lang} est le code de langue, comme fr ou pt-BR. {slug} est l’adresse de l’article dans cette langue.',
Setup: 'Configuration',
'Site locale {0}': 'Locale du site {0}',
'No site locale': 'Aucune locale de site',
'Only the broader language {0} ({1}) is supported. Pages and the API will say {1}, not {2}.':
'Seule la langue plus large {0} ({1}) est prise en charge. Les pages et l’API indiqueront {1}, et non {2}.',
'Seeded from site locale {0}. Confirm or change.':
'Issue de la locale du site {0}. Confirmez ou changez.',
'Required before you can apply.': 'Obligatoire avant de pouvoir appliquer.',
'Seeded from site locale {0}. The full locale is kept. Confirm or change it.':
'Issue de la locale du site {0}. La locale complète est conservée. Confirmez ou changez-la.',
'Set article languages': 'Définir les langues des articles',
'Back to Languages': 'Retour aux langues',
'Content languages for {0}.': 'Langues de contenu de {0}.',
'Languages assigned': 'Langues attribuées',
'Nothing was translated, and no articles were grouped.':
'Rien n’a été traduit et aucun article n’a été regroupé.',
'1 article now has a language. Nothing was translated, and no articles were grouped.':
'1 article a maintenant une langue. Rien n’a été traduit et aucun article n’a été regroupé.',
'{0} articles now have a language. Nothing was translated, and no articles were grouped.':
'{0} articles ont maintenant une langue. Rien n’a été traduit et aucun article n’a été regroupé.',
'Open Articles': 'Ouvrir les articles',
'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.':
'La mise à jour a échoué : aucun article n’a encore de langue. Vos choix sont conservés. Réessayez. L’exécuter deux fois est sans risque.',
'Setup is already running': 'La configuration est déjà en cours',
"Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site's default language. Starting it again is safe: sites that already have a language are skipped.":
'Un autre propriétaire l’a lancée. Les langues apparaissent ici quand elle se termine. Les articles créés entre-temps reçoivent la langue par défaut de leur site. La relancer est sans risque : les sites qui ont déjà une langue sont ignorés.',
'Check again': 'Vérifier à nouveau',
'Assigning languages': 'Attribution des langues',
"Articles created while this runs get the site's default language.":
'Les articles créés pendant l’opération reçoivent la langue par défaut du site.',
'Choose a language for {0}': 'Choisissez une langue pour {0}',
'{0} has no site locale, so there is nothing to start from. Choose the language its articles are written in.':
'{0} n’a pas de locale de site, il n’y a donc rien dont partir. Choisissez la langue dans laquelle ses articles sont écrits.',
'{0} have no site locale, so there is nothing to start from. Choose the language their articles are written in.':
'{0} n’ont pas de locale de site, il n’y a donc rien dont partir. Choisissez la langue dans laquelle leurs articles sont écrits.',
"This labels articles. It doesn't translate them.":
'Cela étiquette les articles. Cela ne les traduit pas.',
"Each existing article becomes the only version of its own article group. Each site's language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.":
'Chaque article existant devient la seule version de son propre groupe d’articles. La langue de chaque site part de sa locale en gardant la région : pt-BR reste portugais (Brésil). Rien n’est appliqué avant votre confirmation.',
'Confirm the default language': 'Confirmer la langue par défaut',
'{0} has no articles yet. New articles will be written in this language.':
'{0} n’a pas encore d’articles. Les nouveaux articles seront rédigés dans cette langue.',
'Default content language': 'Langue de contenu par défaut',
'Choose a language': 'Choisir une langue',
'Assigning…': 'Attribution…',
'Language for {0}': 'Langue de {0}',
'Confirm and assign to {0} articles': 'Confirmer et attribuer à {0} articles',
'Counts are articles in 1 site.': 'Les totaux comptent les articles de 1 site.',
'Counts are articles in {0} sites.': 'Les totaux comptent les articles de {0} sites.',
'{0} · Disabled — re-enable instead': '{0} · Désactivée — réactivez-la plutôt',
'{0} is already enabled for {1}.': '{0} est déjà activée pour {1}.',
'Derived: {0}': 'Dérivée : {0}',
'Not set': 'Non définie',
"{0} isn't available": '{0} n’est pas disponible',
"Only the broader language {0} ({1}) is supported. Pages and the API will say {1}, not {2}. Articles written in this language can't be told apart from other {0}.":
'Seule la langue plus large {0} ({1}) est prise en charge. Les pages et l’API indiqueront {1}, et non {2}. Les articles écrits dans cette langue ne se distinguent pas des autres articles en {0}.',
"Your change wasn't applied.": 'Votre modification n’a pas été appliquée.',
'Add a language': 'Ajouter une langue',
'Choose a language for {0}. Regional variants such as English (United Kingdom) are separate entries.':
'Choisissez une langue pour {0}. Les variantes régionales comme l’anglais (Royaume-Uni) sont des entrées distinctes.',
'Choose a language for {0}.': 'Choisissez une langue pour {0}.',
'Add {0} ({1}) instead': 'Ajouter {0} ({1}) à la place',
'Language code': 'Code de langue',
'Used in the API and in page language metadata.':
'Utilisé dans l’API et dans les métadonnées de langue des pages.',
'Taken from the language code. For a code without a region, such as fr, an owner sets one or leaves it empty and og:locale is left out.':
'Tirée du code de langue. Pour un code sans région, comme fr, un propriétaire en définit une ou la laisse vide et og:locale est omis.',
"Existing versions keep their language, status and links. Public requests that name no language will resolve {0} where a published {0} version exists, and return not found where one doesn't. Requests that name a language aren't affected.":
'Les versions existantes gardent leur langue, leur statut et leurs liens. Les requêtes publiques qui ne nomment aucune langue renverront {0} lorsqu’une version {0} publiée existe, et « introuvable » sinon. Les requêtes qui nomment une langue ne sont pas affectées.',
'Make {0} the default language?': 'Faire de {0} la langue par défaut ?',
'Disable {0}?': 'Désactiver {0} ?',
'Re-enable {0}?': 'Réactiver {0} ?',
"{0} can't be removed": '{0} ne peut pas être retirée',
'New articles start in the default language.':
'Les nouveaux articles commencent dans la langue par défaut.',
"New articles start in the default language, and it is the first choice for each article's title in the list.":
'Les nouveaux articles commencent dans la langue par défaut, qui est le premier choix pour le titre de chaque article dans la liste.',
'Writers can create {0} versions again.':
'Les rédacteurs peuvent de nouveau créer des versions en {0}.',
'No article uses {0}.': 'Aucun article n’utilise {0}.',
'No article uses {0}. You can add it again later.':
'Aucun article n’utilise {0}. Vous pourrez la rajouter plus tard.',
'Nothing changed. Try again.': 'Rien n’a changé. Réessayez.',
"Couldn't re-enable {0}": 'Impossible de réactiver {0}',
'Nothing changed. {0} is still disabled. Try again.':
'Rien n’a changé. {0} est toujours désactivée. Réessayez.',
'Make {0} default': 'Faire de {0} la langue par défaut',
'Disable {0}': 'Désactiver {0}',
'Re-enable {0}': 'Réactiver {0}',
'No content changes': 'Aucun changement de contenu',
'Nothing is deleted or unpublished': 'Rien n’est supprimé ni dépublié',
Disable: 'Désactiver',
'Edit social locale for {0}': 'Modifier la locale sociale de {0}',
'Re-enable': 'Réactiver',
'Use letters and one underscore, for example {0}.':
'Utilisez des lettres et un seul tiret bas, par exemple {0}.',
'Social locale for {0}': 'Locale sociale de {0}',
'Used for og:locale on {0} pages. Leave it empty to omit the tag.':
'Utilisée pour og:locale sur les pages en {0}. Laissez vide pour omettre la balise.',
'Save social locale': 'Enregistrer la locale sociale',
'Format: language_REGION, for example {0}. Letters only, one underscore.':
'Format : langue_RÉGION, par exemple {0}. Lettres uniquement, un seul tiret bas.',
'Derived from the language code': 'Dérivée du code de langue',
'{0} gives {1}. Setting a value replaces the derived one.':
'{0} donne {1}. Définir une valeur remplace la valeur dérivée.',
'1 version · {0} published': '1 version · {0} publiée',
'{0} versions · {1} published': '{0} versions · {1} publiées',
'Set: {0}': 'Définie : {0}',
'1 draft': '1 brouillon',
'{0} drafts': '{0} brouillons',
'{0} published': '{0} publiées',
' and ': ' et ',
'{0} is used by 1 version: {1}.': '{0} est utilisée par 1 version : {1}.',
'{0} is used by {1} versions: {2}.': '{0} est utilisée par {1} versions : {2}.',
'The published {0} version stays online.': 'La version {0} publiée reste en ligne.',
'The {0} published {1} versions stay online.':
'Les {0} versions {1} publiées restent en ligne.',
'The version stays readable and editable.': 'La version reste lisible et modifiable.',
'All {0} versions stay readable and editable.':
'Les {0} versions restent lisibles et modifiables.',
"The draft can't be published until you re-enable {0}.":
'Le brouillon ne peut pas être publié avant la réactivation de {0}.',
"The {0} drafts can't be published until you re-enable {1}.":
'Les {0} brouillons ne peuvent pas être publiés avant la réactivation de {1}.',
'Nobody can create new {0} versions.': 'Personne ne peut créer de nouvelles versions en {0}.',
'Writers can create {0} versions again. Nothing else changes.':
'Les rédacteurs peuvent de nouveau créer des versions en {0}. Rien d’autre ne change.',
'Writers can create {0} versions again. The 1 existing {0} version stays as it is. Nothing else changes.':
'Les rédacteurs peuvent de nouveau créer des versions en {0}. La version {0} existante reste telle quelle. Rien d’autre ne change.',
'Writers can create {0} versions again. The {1} existing {0} versions stay as they are. Nothing else changes.':
'Les rédacteurs peuvent de nouveau créer des versions en {0}. Les {1} versions {0} existantes restent telles quelles. Rien d’autre ne change.',
'{0} is used by 1 version. Disable the language to keep it, or delete it first.':
'{0} est utilisée par 1 version. Désactivez la langue pour la garder, ou supprimez-la d’abord.',
'{0} is used by {1} versions. Disable the language to keep it, or delete it first.':
'{0} est utilisée par {1} versions. Désactivez la langue pour les garder, ou supprimez-les d’abord.',
'{0} is used by 1 version.': '{0} est utilisée par 1 version.',
'{0} is used by {1} versions.': '{0} est utilisée par {1} versions.',
'{0} added': '{0} ajoutée',
'Writers can now create {0} versions.':
'Les rédacteurs peuvent maintenant créer des versions en {0}.',
'{0} re-enabled': '{0} réactivée',
'Writers can create {0} versions again. Drafts can be published.':
'Les rédacteurs peuvent de nouveau créer des versions en {0}. Les brouillons peuvent être publiés.',
'Start with https:// or http://.': 'Commencez par https:// ou http://.',
'Unknown placeholder {0}. Use {lang} and {slug}.':
'Variable inconnue {0}. Utilisez {lang} et {slug}.',
'Unknown placeholder. Use {lang} and {slug}.': 'Variable inconnue. Utilisez {lang} et {slug}.',
'Remove spaces and characters that need encoding. Use letters, digits, - and _.':
'Retirez les espaces et les caractères à encoder. Utilisez des lettres, des chiffres, - et _.',
'Add {slug} so each article has its own address.':
'Ajoutez {slug} pour que chaque article ait sa propre adresse.',
'Preview with "{0}" in {1}': 'Aperçu avec « {0} » en {1}',
'Public URL saved': 'URL publique enregistrée',
'{0} pages now use this pattern.': 'Les pages de {0} utilisent maintenant ce modèle.',
'List the content languages of a site: the default first, then the other enabled ones, then the disabled ones. Each carries its version and published counts and its social locale. Send the revision back when you change them. A site with no confirmed default answers needs_setup true and no languages.':
'Liste les langues de contenu d’un site : la langue par défaut d’abord, puis les autres langues activées, puis les désactivées. Chacune porte son nombre de versions et de versions publiées, et sa locale sociale. Renvoyez la révision quand vous les modifiez. Un site sans langue par défaut confirmée répond needs_setup true et aucune langue.',
'Replace the content languages of a site': 'Remplacer les langues de contenu d’un site',
'Language setup · owner only': 'Configuration des langues · propriétaire uniquement',
'Sites that still need a default language':
'Sites qui ont encore besoin d’une langue par défaut',
'Confirm the default language of every pending site':
'Confirmer la langue par défaut de chaque site en attente',
} as const
+3
View File
@@ -1,4 +1,7 @@
import { localizationMessages } from './localizationMessages'
export const messages = { export const messages = {
...localizationMessages,
'Not found': 'Introuvable', 'Not found': 'Introuvable',
'Edit article': 'Modifier l’article', 'Edit article': 'Modifier l’article',
unselected: 'désélectionnée', unselected: 'désélectionnée',
+6 -6
View File
@@ -65,10 +65,10 @@ describe('index', () => {
]) ])
}) })
it('holds 85 endpoints: 42 documented, 37 session only and 6 public auth', () => { it('holds 89 endpoints: 43 documented, 40 session only and 6 public auth', () => {
expect(index.endpoints).toHaveLength(85) expect(index.endpoints).toHaveLength(89)
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(42) expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(43)
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(37) 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)
}) })
@@ -179,7 +179,7 @@ describe('matching', () => {
expect(routes(login.sections)).toEqual(['section Session only']) expect(routes(login.sections)).toEqual(['section Session only'])
expect(login.sections[0]!.entry).toMatchObject({ expect(login.sections[0]!.entry).toMatchObject({
locked: true, locked: true,
description: '37 routes, not available to API keys', description: '40 routes, not available to API keys',
}) })
}) })
@@ -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', 23, 1], ['404', 24, 1],
['login', 1, 1], ['login', 1, 1],
['entry', 7, 1], ['entry', 7, 1],
['capacity', 2, 0], ['capacity', 2, 0],
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
checkPublicUrlPattern,
previewPublicUrl,
unknownPlaceholderOf,
} from '@/lib/publicUrlPattern'
describe('Public URL pattern checks', () => {
it.each([
['https://journal.example.com/{lang}/{slug}', null],
['https://journal.example.com/{lang}/articles/{slug}', null],
['http://localhost:3000/{slug}', null],
['https://journal.example.com/{slug}/{slug}', null],
['https://journal.example.com/a%20b/{slug}', null],
[' https://journal.example.com/{slug} ', null],
['', null],
['ftp://journal.example.com/{lang}/{slug}', 'invalid_scheme'],
['journal.example.com/{slug}', 'invalid_scheme'],
['https://', 'invalid_host'],
['https:///{slug}', 'invalid_host'],
['https://journal.example.com/{language}/{slug}', 'unknown_placeholder'],
['https://journal.example.com/{lang', 'unknown_placeholder'],
['https://journal.example.com/lang}/{slug}', 'unknown_placeholder'],
['https://journal.example.com/my pages/{lang}/{slug}', 'not_encoded'],
['https://journal.example.com/é/{slug}', 'not_encoded'],
['https://journal.example.com/100%/{slug}', 'not_encoded'],
['https://journal.example.com/{lang}', 'missing_slug'],
['ftp://x.example.com/{language} here', 'invalid_scheme'],
['https://x.example.com/{language} here', 'unknown_placeholder'],
['https://x.example.com/a b', 'not_encoded'],
])('checks %j as %s', (value, expected) => {
expect(checkPublicUrlPattern(value)).toBe(expected)
})
it('names the first placeholder that is not known', () => {
expect(unknownPlaceholderOf('https://x.example.com/{language}/{slug}')).toBe('{language}')
expect(unknownPlaceholderOf('https://x.example.com/{lang')).toBe('{lang')
expect(unknownPlaceholderOf('https://x.example.com/{lang}/{slug}')).toBeNull()
})
})
describe('Public URL preview', () => {
it('replaces every placeholder', () => {
expect(
previewPublicUrl('https://journal.example.com/{lang}/{slug}/{slug}', 'fr', 'guide'),
).toBe('https://journal.example.com/fr/guide/guide')
})
})
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import {
effectiveSocialLocale,
isSocialLocaleInput,
languageName,
derivedSocialLocale,
languagesUpdate,
socialLocaleExample,
withDefault,
withEnabled,
withLanguage,
withoutLanguage,
withSocialLocale,
type SiteLanguages,
} from '@/lib/siteLanguages'
function journal(): SiteLanguages {
const row = (lang: string, enabled: boolean, extra = {}) => ({
lang,
enabled,
is_default: lang === 'en',
version_count: 1,
published_count: 1,
social_locale: null,
social_locale_state: 'not_set' as const,
...extra,
})
return {
revision: 'r1',
default: 'en',
needs_setup: false,
languages: [
row('en', true),
row('fr', true, { social_locale: 'fr_FR', social_locale_state: 'set' }),
row('es', false),
],
available: [{ lang: 'pt-BR', substitute: null }],
}
}
describe('Language names', () => {
it.each([
['en', 'en', 'English'],
['pt-BR', 'en', 'Portuguese (Brazil)'],
['en-GB', 'en', 'English (United Kingdom)'],
['pt-PT', 'en', 'Portuguese (Portugal)'],
['fr', 'fr', 'Français'],
])('names %s in %s as %s', (tag, locale, expected) => {
expect(languageName(tag, locale as 'en' | 'fr')).toBe(expected)
})
it('falls back to the tag when it has no name', () => {
expect(languageName('not a tag', 'en')).toBe('not a tag')
})
})
describe('Social locale', () => {
it('is explicit when set, derived from a regional tag, and absent otherwise', () => {
const rows = journal().languages
expect(effectiveSocialLocale(rows[1]!)).toBe('fr_FR')
expect(effectiveSocialLocale(rows[0]!)).toBeNull()
expect(
effectiveSocialLocale({
...rows[0]!,
lang: 'pt-BR',
social_locale_state: 'derived',
}),
).toBe('pt_BR')
})
it.each([
['fr_FR', 'fr', true],
['fr_CA', 'fr', true],
['fil_PH', 'fil', true],
['fr-FR', 'fr', false],
['fr', 'fr', false],
['', 'fr', false],
['pt_BR', 'fr', false],
['fr_', 'fr', false],
['fr_FRA', 'fr', false],
])('checks %j on %s as %s', (value, tag, expected) => {
expect(isSocialLocaleInput(value, tag)).toBe(expected)
})
})
describe('Building the full state to send', () => {
it('sends every row with the revision and the default', () => {
expect(languagesUpdate(journal())).toEqual({
revision: 'r1',
default: 'en',
languages: [
{ lang: 'en', enabled: true, social_locale: null },
{ lang: 'fr', enabled: true, social_locale: 'fr_FR' },
{ lang: 'es', enabled: false, social_locale: null },
],
})
})
it('adds a language as an enabled row', () => {
const body = languagesUpdate(withLanguage(journal(), 'pt-BR'))
expect(body.languages.at(-1)).toEqual({
lang: 'pt-BR',
enabled: true,
social_locale: null,
})
})
it('changes the default without touching enabled flags', () => {
const body = languagesUpdate(withDefault(journal(), 'fr'))
expect(body.default).toBe('fr')
expect(body.languages.map((row) => row.enabled)).toEqual([true, true, false])
})
it('disables and re-enables one language', () => {
const off = languagesUpdate(withEnabled(journal(), 'fr', false))
expect(off.languages[1]!.enabled).toBe(false)
const on = languagesUpdate(withEnabled(journal(), 'es', true))
expect(on.languages[2]!.enabled).toBe(true)
})
it('removes a language', () => {
const body = languagesUpdate(withoutLanguage(journal(), 'es'))
expect(body.languages.map((row) => row.lang)).toEqual(['en', 'fr'])
})
it('sets and clears a social locale', () => {
expect(
languagesUpdate(withSocialLocale(journal(), 'fr', 'fr_CA')).languages[1]!.social_locale,
).toBe('fr_CA')
expect(
languagesUpdate(withSocialLocale(journal(), 'fr', null)).languages[1]!.social_locale,
).toBeNull()
})
})
describe('Social locale examples', () => {
it.each([
['fr', 'fr_FR'],
['en', 'en_US'],
['pt-BR', 'pt_BR'],
['en-GB', 'en_GB'],
['zh-Hans', 'zh_CN'],
['de', 'de_DE'],
])('suggests %s as %s', (tag, expected) => {
expect(socialLocaleExample(tag)).toBe(expected)
})
it('derives a value only from a tag with a region', () => {
expect(derivedSocialLocale('pt-BR')).toBe('pt_BR')
expect(derivedSocialLocale('fr')).toBeNull()
expect(derivedSocialLocale('zh-Hans')).toBeNull()
})
})
+108
View File
@@ -0,0 +1,108 @@
import { t } from '@/i18n'
import { effectiveSocialLocale, type SiteLanguage } from '@/lib/siteLanguages'
export function versionsLine(language: SiteLanguage) {
return language.version_count === 1
? t('1 version · {0} published', [language.published_count])
: t('{0} versions · {1} published', [language.version_count, language.published_count])
}
export function socialLocaleLabel(language: SiteLanguage) {
const value = effectiveSocialLocale(language)
if (language.social_locale_state === 'set') {
return t('Set: {0}', [value ?? ''])
}
if (language.social_locale_state === 'derived') {
return t('Derived: {0}', [value ?? ''])
}
return t('Not set')
}
function draftsOf(language: SiteLanguage) {
return language.version_count - language.published_count
}
function usageParts(language: SiteLanguage) {
const drafts = draftsOf(language)
const parts: string[] = []
if (drafts > 0) {
parts.push(drafts === 1 ? t('1 draft') : t('{0} drafts', [drafts]))
}
if (language.published_count > 0) {
parts.push(t('{0} published', [language.published_count]))
}
return parts.join(t(' and '))
}
export function disableDescription(name: string, language: SiteLanguage) {
if (language.version_count === 0) {
return t('No article uses {0}.', [name])
}
const count =
language.version_count === 1
? t('{0} is used by 1 version: {1}.', [name, usageParts(language)])
: t('{0} is used by {1} versions: {2}.', [
name,
language.version_count,
usageParts(language),
])
return count
}
export function disableWarning(name: string, language: SiteLanguage) {
const drafts = draftsOf(language)
const sentences: string[] = []
if (language.published_count === 1) {
sentences.push(t('The published {0} version stays online.', [name]))
} else if (language.published_count > 1) {
sentences.push(
t('The {0} published {1} versions stay online.', [language.published_count, name]),
)
}
sentences.push(
language.version_count === 1
? t('The version stays readable and editable.')
: t('All {0} versions stay readable and editable.', [language.version_count]),
)
if (drafts === 1) {
sentences.push(t("The draft can't be published until you re-enable {0}.", [name]))
} else if (drafts > 1) {
sentences.push(
t("The {0} drafts can't be published until you re-enable {1}.", [drafts, name]),
)
}
sentences.push(t('Nobody can create new {0} versions.', [name]))
return sentences.join(' ')
}
export function reenableDescription(name: string, language: SiteLanguage) {
if (language.version_count === 0) {
return t('Writers can create {0} versions again. Nothing else changes.', [name])
}
return language.version_count === 1
? t(
'Writers can create {0} versions again. The 1 existing {0} version stays as it is. Nothing else changes.',
[name],
)
: t(
'Writers can create {0} versions again. The {1} existing {0} versions stay as they are. Nothing else changes.',
[name, language.version_count],
)
}
export function removeBlockedDescription(name: string, language: SiteLanguage) {
return language.version_count === 1
? t('{0} is used by 1 version. Disable the language to keep it, or delete it first.', [
name,
])
: t('{0} is used by {1} versions. Disable the language to keep it, or delete it first.', [
name,
language.version_count,
])
}
export function usedByLine(name: string, language: SiteLanguage) {
return language.version_count === 1
? t('{0} is used by 1 version.', [name])
: t('{0} is used by {1} versions.', [name, language.version_count])
}
+47
View File
@@ -0,0 +1,47 @@
export type PublicUrlPatternError =
| 'invalid_scheme'
| 'invalid_host'
| 'unknown_placeholder'
| 'not_encoded'
| 'missing_slug'
export const PUBLIC_URL_PATTERN_MAX_LENGTH = 512
const HTTP_SCHEME = /^https?:\/\//i
const SCHEME_WITH_HOST = /^https?:\/\/[^/?#\s]+/i
const BRACE_GROUP = /\{[^{}]*\}?|\}/g
const KNOWN_PLACEHOLDERS = new Set(['{lang}', '{slug}'])
const ENCODED_CHARACTERS = /^(?:[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]|%[0-9A-Fa-f]{2})*$/
export function unknownPlaceholderOf(pattern: string) {
const found = pattern.match(BRACE_GROUP) ?? []
return found.find((token) => !KNOWN_PLACEHOLDERS.has(token)) ?? null
}
export function checkPublicUrlPattern(value: string): PublicUrlPatternError | null {
const pattern = value.trim()
if (pattern === '') {
return null
}
if (!HTTP_SCHEME.test(pattern)) {
return 'invalid_scheme'
}
if (!SCHEME_WITH_HOST.test(pattern)) {
return 'invalid_host'
}
if (unknownPlaceholderOf(pattern) !== null) {
return 'unknown_placeholder'
}
const literal = pattern.replace(/\{lang\}|\{slug\}/g, '')
if (!ENCODED_CHARACTERS.test(literal)) {
return 'not_encoded'
}
return pattern.includes('{slug}') ? null : 'missing_slug'
}
export function previewPublicUrl(pattern: string, lang: string, slug: string) {
return pattern
.trim()
.replace(/\{lang\}/g, lang)
.replace(/\{slug\}/g, slug)
}
+153
View File
@@ -0,0 +1,153 @@
export type SocialLocaleState = 'set' | 'derived' | 'not_set'
export interface SiteLanguage {
lang: string
enabled: boolean
is_default: boolean
version_count: number
published_count: number
social_locale: string | null
social_locale_state: SocialLocaleState
}
export interface AvailableLanguage {
lang: string
substitute: string | null
}
export interface SiteLanguages {
revision: string
default: string | null
needs_setup: boolean
languages: SiteLanguage[]
available: AvailableLanguage[]
}
export interface LanguageEntry {
lang: string
enabled: boolean
social_locale: string | null
}
export interface LanguagesUpdate {
revision: string
default: string
languages: LanguageEntry[]
}
export interface EditedLanguages {
revision: string
default: string
entries: LanguageEntry[]
}
const SOCIAL_LOCALE_PATTERN = /^[A-Za-z]{2,3}_[A-Za-z]{2}$/
export function languageName(tag: string, locale: 'en' | 'fr') {
let name = tag
try {
name =
new Intl.DisplayNames([locale], { type: 'language', languageDisplay: 'standard' }).of(
tag,
) ?? tag
} catch {
return tag
}
return name.charAt(0).toLocaleUpperCase(locale) + name.slice(1)
}
export function baseLanguage(tag: string) {
return tag.split('-')[0]!.toLowerCase()
}
export function effectiveSocialLocale(language: SiteLanguage) {
if (language.social_locale_state === 'set') {
return language.social_locale
}
if (language.social_locale_state === 'derived') {
return language.lang.replace('-', '_')
}
return null
}
export function isSocialLocaleInput(value: string, tag: string) {
return (
SOCIAL_LOCALE_PATTERN.test(value) &&
baseLanguage(value.split('_')[0]!) === baseLanguage(tag)
)
}
export function editedLanguages(state: SiteLanguages): EditedLanguages {
return {
revision: state.revision,
default: state.default ?? '',
entries: state.languages.map((row) => ({
lang: row.lang,
enabled: row.enabled,
social_locale: row.social_locale,
})),
}
}
function mapEntry(
state: SiteLanguages,
tag: string,
change: (entry: LanguageEntry) => LanguageEntry,
): EditedLanguages {
const edited = editedLanguages(state)
return {
...edited,
entries: edited.entries.map((entry) => (entry.lang === tag ? change(entry) : entry)),
}
}
export function withLanguage(state: SiteLanguages, tag: string): EditedLanguages {
const edited = editedLanguages(state)
return {
...edited,
entries: [...edited.entries, { lang: tag, enabled: true, social_locale: null }],
}
}
export function withDefault(state: SiteLanguages, tag: string): EditedLanguages {
return { ...editedLanguages(state), default: tag }
}
export function withEnabled(state: SiteLanguages, tag: string, enabled: boolean) {
return mapEntry(state, tag, (entry) => ({ ...entry, enabled }))
}
export function withoutLanguage(state: SiteLanguages, tag: string): EditedLanguages {
const edited = editedLanguages(state)
return { ...edited, entries: edited.entries.filter((entry) => entry.lang !== tag) }
}
export function withSocialLocale(state: SiteLanguages, tag: string, value: string | null) {
return mapEntry(state, tag, (entry) => ({ ...entry, social_locale: value }))
}
export function languagesUpdate(change: SiteLanguages | EditedLanguages): LanguagesUpdate {
const edited = 'entries' in change ? change : editedLanguages(change)
return { revision: edited.revision, default: edited.default, languages: edited.entries }
}
const EXAMPLE_REGIONS: Record<string, string> = { en: 'US', ar: 'EG', ja: 'JP', zh: 'CN', pt: 'PT' }
export function regionOf(tag: string) {
return tag
.split('-')
.slice(1)
.find((part) => /^[A-Za-z]{2}$/.test(part))
?.toUpperCase()
}
export function socialLocaleExample(tag: string) {
const base = baseLanguage(tag)
const region = regionOf(tag) ?? EXAMPLE_REGIONS[base] ?? base.toUpperCase()
return `${base}_${region}`
}
export function derivedSocialLocale(tag: string) {
const region = regionOf(tag)
return region ? `${baseLanguage(tag)}_${region}` : null
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { createMemoryHistory } from 'vue-router'
import { createAppRouter, resolveAuthRedirect } from '@/router'
import { isRouteAllowed } from '@/lib/routeAccess'
import { accessFor } from '@/test/access'
import { createPinia, setActivePinia } from 'pinia'
import { useSessionStore } from '@/stores/session'
const router = createAppRouter(createMemoryHistory())
function meta(path: string) {
return router.resolve(path).meta
}
describe('Localization routes', () => {
it.each([
['/settings/sites/journal/languages'],
['/settings/sites/journal/public-url'],
['/settings/sites/journal/languages/setup'],
])('%s needs a verified session', (path) => {
expect(meta(path).auth).toBe('verified')
})
it.each([['/settings/sites/journal/languages'], ['/settings/sites/journal/public-url']])(
'%s is open to every verified user',
(path) => {
expect(meta(path).access).toBeUndefined()
},
)
it('keeps the setup page for the permission to manage sites', () => {
const access = meta('/settings/sites/journal/languages/setup').access as { key: string }
expect(access.key).toBe('settings.manage_sites')
const owner = new Set(accessFor('owner')!.permissions.map((grant) => grant.key))
const reader = new Set(accessFor('viewer')!.permissions.map((grant) => grant.key))
expect(isRouteAllowed(access, {}, (key) => owner.has(key))).toBe(true)
expect(isRouteAllowed(access, {}, (key) => reader.has(key))).toBe(false)
})
it('passes the site key as a route parameter', () => {
expect(router.resolve('/settings/sites/archive/languages').params.key).toBe('archive')
expect(router.resolve('/settings/sites/archive/languages/setup').params.key).toBe('archive')
expect(router.resolve('/settings/sites/archive/public-url').params.key).toBe('archive')
})
it.each([
['/settings/sites/journal/languages'],
['/settings/sites/journal/public-url'],
['/settings/sites/journal/languages/setup'],
])('redirects an anonymous visitor of %s to the login page', (path) => {
setActivePinia(createPinia())
const session = useSessionStore()
session.status = 'ready'
session.session = null
const destination = resolveAuthRedirect(router.resolve(path), session)
expect(destination).toEqual({
path: '/login',
query: { redirect: path, reason: 'expired' },
})
})
})
+18
View File
@@ -227,6 +227,24 @@ const routes: RouteRecordRaw[] = [
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')), component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } }, meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
}, },
{
path: '/settings/sites/:key/languages',
component: () => import('@/views/SiteLanguagesView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/sites/:key/languages/setup',
component: gated(
'@/views/LanguageSetupView.vue',
() => import('@/views/LanguageSetupView.vue'),
),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
},
{
path: '/settings/sites/:key/public-url',
component: () => import('@/views/PublicUrlView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{ {
path: '/settings/permissions', path: '/settings/permissions',
redirect: '/settings/roles', redirect: '/settings/roles',
+380
View File
@@ -0,0 +1,380 @@
<script setup lang="ts">
import { t, locale } from '@/i18n'
import { computed, onMounted, toRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import LocaleInput from '@/components/localization/LocaleInput.vue'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import RetryAlert from '@/components/RetryAlert.vue'
import { useLanguageSetupPage } from '@/composables/useLanguageSetupPage'
import type { SetupSite } from '@/composables/useSiteLanguages'
import { baseLanguage, languageName } from '@/lib/siteLanguages'
import { useSessionStore } from '@/stores/session'
const route = useRoute()
const session = useSessionStore()
const siteKey = computed(() => String(route.params.key ?? ''))
const page = useLanguageSetupPage(toRef(siteKey))
const { loadState, sites, stage } = page
const sessionReady = computed(() => session.status === 'ready')
const known = computed(() => page.knownSiteName.value !== null)
const siteName = computed(() => page.knownSiteName.value ?? siteKey.value)
const languagesPath = computed(() =>
known.value
? `/settings/sites/${encodeURIComponent(siteKey.value)}/languages`
: '/settings/sites',
)
const items = computed(() =>
(page.setup.value?.available ?? [])
.filter((option) => option.substitute === null)
.map((option) => ({
value: option.lang,
label: optionLabel(option.lang),
})),
)
const unseededNames = computed(() =>
sites.value.filter((site) => site.suggestion === null).map((site) => site.name),
)
const confirmDisabled = computed(
() => !page.everyRowChosen.value || stage.value === 'applying' || stage.value === 'inProgress',
)
const showRequiredError = computed(() => page.onlyUnseeded.value && stage.value === 'editing')
onMounted(page.load)
watch(siteKey, page.load)
const breadcrumb = computed(() => [
{ label: t('Settings'), to: '/settings' },
{ label: t('Sites'), to: '/settings/sites' },
...(known.value
? [
{ label: siteName.value },
{ label: t('Languages'), to: languagesPath.value },
{ label: t('Setup') },
]
: [{ label: t('Setup') }]),
])
function optionLabel(tag: string) {
return `${languageName(tag, locale.value)} · ${tag}`
}
function articles(count: number) {
return count === 1 ? t('1 article') : t('{0} articles', [count])
}
function localeNote(site: SetupSite) {
return site.locale ? t('Site locale {0}', [site.locale]) : t('No site locale')
}
function help(site: SetupSite) {
const chosen = page.choices.value[site.key]
if (stage.value === 'done') {
return undefined
}
if (site.suggestion?.substituted && site.locale) {
return t(
'Only the broader language {0} ({1}) is supported. Pages and the API will say {1}, not {2}.',
[
languageName(baseLanguage(site.suggestion.lang), locale.value),
site.suggestion.lang,
site.locale,
],
)
}
if (site.suggestion && site.locale) {
return t('Seeded from site locale {0}. Confirm or change.', [site.locale])
}
return chosen ? undefined : t('Required before you can apply.')
}
function emptySiteHelp(site: SetupSite) {
return site.suggestion && site.locale && !site.suggestion.substituted
? t('Seeded from site locale {0}. The full locale is kept. Confirm or change it.', [
site.locale,
])
: help(site)
}
function fieldError(site: SetupSite) {
return showRequiredError.value && !page.choices.value[site.key]
? t('Required before you can apply.')
: undefined
}
function shown(site: SetupSite) {
const chosen = page.choices.value[site.key]
return chosen ? optionLabel(chosen) : ''
}
</script>
<template>
<DashboardShell
compact-padding
:mobile-title="t('Set article languages')"
:back-to="languagesPath"
:back-label="known ? t('Back to Languages') : t('Back to Sites')"
no-current-nav
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div v-if="sessionReady" class="flex flex-col gap-4">
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">
{{ page.singleEmptySite.value ? t('Languages') : t('Set article languages') }}
</h1>
<p class="text-pretty text-toned">
{{
page.singleEmptySite.value
? t('Content languages for {0}.', [sites[0]!.name])
: t(
"Existing articles don't have a language yet. Confirm or change the language for each site.",
)
}}
</p>
</div>
<div
v-if="loadState === 'loading'"
class="h-44 bg-elevated"
role="status"
:aria-label="t('Loading')"
/>
<RetryAlert
v-else-if="loadState === 'error'"
:title="t('Couldn\'t load languages')"
:description="t('Check your connection and try again.')"
@retry="page.load"
/>
<template v-else-if="loadState === 'ready'">
<LocaleAlert
v-if="stage === 'done'"
tone="success"
icon="i-lucide-circle-check"
:title="t('Languages assigned')"
:description="
page.assigned.value === null
? t('Nothing was translated, and no articles were grouped.')
: page.assigned.value === 1
? t(
'1 article now has a language. Nothing was translated, and no articles were grouped.',
)
: t(
'{0} articles now have a language. Nothing was translated, and no articles were grouped.',
[page.assigned.value],
)
"
>
<template #actions>
<LocaleButton small to="/article">{{ t('Open Articles') }}</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-else-if="stage === 'failed'"
tone="error"
icon="i-lucide-circle-x"
:title="t('Nothing was changed')"
:description="
t(
'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.',
)
"
>
<template #actions>
<LocaleButton small @click="page.apply">{{ t('Try again') }}</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-else-if="stage === 'inProgress'"
tone="info"
icon="i-lucide-loader-circle"
:title="t('Setup is already running')"
:description="
t(
'Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site\'s default language. Starting it again is safe: sites that already have a language are skipped.',
)
"
>
<template #actions>
<LocaleButton small :loading="page.checking.value" @click="page.checkAgain">
{{ t('Check again') }}
</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-else-if="stage === 'applying'"
tone="info"
icon="i-lucide-loader-circle"
:title="t('Assigning languages')"
:description="
t('Articles created while this runs get the site\'s default language.')
"
/>
<LocaleAlert
v-else-if="showRequiredError"
tone="error"
icon="i-lucide-circle-x"
:title="t('Choose a language for {0}', [unseededNames.join(', ')])"
:description="
unseededNames.length === 1
? t(
'{0} has no site locale, so there is nothing to start from. Choose the language its articles are written in.',
[unseededNames[0]],
)
: t(
'{0} have no site locale, so there is nothing to start from. Choose the language their articles are written in.',
[unseededNames.join(', ')],
)
"
/>
<LocaleAlert
v-else-if="sites.length > 0 && !page.singleEmptySite.value"
tone="info"
icon="i-lucide-info"
:title="t('This labels articles. It doesn\'t translate them.')"
:description="
t(
'Each existing article becomes the only version of its own article group. Each site\'s language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.',
)
"
/>
<div
v-if="page.singleEmptySite.value && stage !== 'done'"
class="flex flex-col gap-3 border border-default bg-default p-4"
>
<span class="font-semibold text-highlighted">{{
t('Confirm the default language')
}}</span>
<span class="text-toned">{{
t(
'{0} has no articles yet. New articles will be written in this language.',
[sites[0]!.name],
)
}}</span>
<UFormField
:label="t('Default content language')"
:help="emptySiteHelp(sites[0]!)"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
}"
>
<LocaleSelect
:model-value="page.choices.value[sites[0]!.key]"
:items="items"
:placeholder="t('Choose a language')"
:aria-label="t('Default content language')"
@update:model-value="(lang) => page.choose(sites[0]!.key, lang)"
/>
</UFormField>
<div class="flex">
<LocaleButton
kind="primary"
:disabled="confirmDisabled"
:loading="stage === 'applying'"
@click="page.apply"
>
{{ stage === 'applying' ? t('Assigning…') : t('Save') }}
</LocaleButton>
</div>
</div>
<template v-else-if="sites.length > 0">
<div
class="border border-default bg-default"
role="group"
:aria-label="t('Sites')"
>
<div
v-for="site in sites"
:key="site.key"
class="flex min-h-12 flex-wrap items-start gap-3 border-t border-default px-4 py-2.5 max-md:min-h-14 md:items-center"
:data-testid="`setup-row-${site.key}`"
>
<div class="flex min-w-35 flex-1 flex-col">
<span class="font-semibold text-highlighted">{{ site.name }}</span>
<span class="text-xs/5 text-muted">
{{ articles(site.article_count) }} · {{ localeNote(site) }}
</span>
</div>
<div class="w-full pt-1.5 md:w-75">
<UFormField
:error="fieldError(site)"
:help="help(site)"
:ui="{
container: 'mt-0',
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
error: 'mt-1.5 flex gap-1.5 text-xs/4.5 text-error',
}"
>
<LocaleInput
v-if="stage === 'done' || stage === 'inProgress'"
:model-value="shown(site)"
readonly
:flat="stage === 'done'"
:aria-label="t('Language for {0}', [site.name])"
/>
<LocaleSelect
v-else
:model-value="page.choices.value[site.key]"
:items="items"
:placeholder="t('Choose a language')"
:aria-label="t('Language for {0}', [site.name])"
:invalid="fieldError(site) !== undefined"
@update:model-value="(lang) => page.choose(site.key, lang)"
/>
<template v-if="fieldError(site)" #error>
<UIcon
name="i-lucide-circle-x"
class="mt-0.5 size-3.5 shrink-0"
/>
<span>{{ fieldError(site) }}</span>
</template>
</UFormField>
</div>
</div>
</div>
<div v-if="stage !== 'done'" class="flex flex-wrap items-center gap-2">
<LocaleButton
kind="primary"
:disabled="confirmDisabled"
:loading="stage === 'applying'"
@click="page.apply"
>
{{
stage === 'applying'
? t('Assigning…')
: t('Confirm and assign to {0} articles', [
page.articleTotal.value,
])
}}
</LocaleButton>
<span class="text-xs/5 text-muted">
{{
sites.length === 1
? t('Counts are articles in 1 site.')
: t('Counts are articles in {0} sites.', [sites.length])
}}
</span>
</div>
</template>
</template>
</div>
</DashboardShell>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, toRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import LocaleInput from '@/components/localization/LocaleInput.vue'
import RetryAlert from '@/components/RetryAlert.vue'
import { usePublicUrlPage } from '@/composables/usePublicUrlPage'
import { PUBLIC_URL_PATTERN_MAX_LENGTH } from '@/lib/publicUrlPattern'
import { usePermissionsStore } from '@/stores/permissions'
import { useSessionStore } from '@/stores/session'
const route = useRoute()
const session = useSessionStore()
const permissions = usePermissionsStore()
const siteKey = computed(() => String(route.params.key ?? ''))
const page = usePublicUrlPage(toRef(siteKey))
const { loadState, value, saving, saveFailed, unset, canSave, errorMessage } = page
const sessionReady = computed(() => session.status === 'ready')
const owner = computed(() => permissions.can('settings.manage_sites'))
const siteName = computed(() => page.site.value?.name ?? siteKey.value)
const ready = computed(() => loadState.value === 'ready')
const invalid = computed(() => errorMessage.value !== undefined)
onMounted(page.load)
watch(siteKey, page.load)
const breadcrumb = computed(() => [
{ label: t('Settings'), to: '/settings' },
{ label: t('Sites'), to: '/settings/sites' },
{ label: siteName.value },
{ label: t('Public URL') },
])
</script>
<template>
<DashboardShell
compact-padding
:mobile-title="t('{0} · Public URL', [siteName])"
back-to="/settings/sites"
:back-label="t('Back to Sites')"
no-current-nav
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div v-if="sessionReady" class="flex flex-col gap-4">
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Public URL') }}</h1>
<p class="text-pretty text-toned">
{{
t(
'Where {0} articles appear on your website. Used for canonical and alternate links.',
[siteName],
)
}}
</p>
</div>
<div
v-if="loadState === 'loading'"
class="h-44 bg-elevated"
role="status"
:aria-label="t('Loading')"
/>
<RetryAlert
v-else-if="loadState === 'error'"
:title="t('Couldn\'t load languages')"
:description="t('Check your connection and try again.')"
@retry="page.load"
/>
<template v-else-if="ready">
<LocaleAlert
v-if="!owner"
tone="neutral"
icon="i-lucide-lock"
:title="t('Only owners can change this')"
:description="t('You can read the pattern. Ask an owner to change it.')"
/>
<LocaleAlert
v-else-if="unset"
tone="info"
icon="i-lucide-info"
:title="t('Public URL not configured')"
:description="
t(
'Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.',
)
"
/>
<LocaleAlert
v-if="saveFailed"
tone="error"
icon="i-lucide-circle-x"
:title="t('Couldn\'t save languages')"
:description="t('Nothing changed. Your selection is kept. Try again.')"
/>
<UForm
:state="{ value }"
class="flex flex-col gap-3 border border-default bg-default p-4"
@submit="page.save"
>
<UFormField
:label="t('Public URL pattern')"
:error="errorMessage"
:help="
t(
'Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.',
)
"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
error: 'mt-1.5 flex gap-1.5 text-xs/4.5 text-error',
}"
>
<LocaleInput
:model-value="value"
mono
:readonly="!owner"
:invalid="invalid"
placeholder="https://example.com/{lang}/{slug}"
:aria-label="t('Public URL pattern')"
:maxlength="PUBLIC_URL_PATTERN_MAX_LENGTH"
@update:model-value="page.edit"
/>
<template v-if="errorMessage" #error>
<UIcon name="i-lucide-circle-x" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ errorMessage }}</span>
</template>
</UFormField>
<div
v-if="page.previewUrl.value"
class="flex flex-col gap-1"
data-testid="preview"
>
<span class="text-xs/5 text-muted">{{ page.previewLabel.value }}</span>
<span class="font-mono text-xs/5 wrap-anywhere text-highlighted">{{
page.previewUrl.value
}}</span>
</div>
<div v-if="owner" class="flex gap-2">
<LocaleButton
type="submit"
kind="primary"
:disabled="!canSave"
:loading="saving"
>
{{ t('Save') }}
</LocaleButton>
<LocaleButton :disabled="saving" @click="page.cancel">
{{ t('Cancel') }}
</LocaleButton>
</div>
</UForm>
</template>
</div>
</DashboardShell>
</template>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, toRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import AddLanguageDialog from '@/components/localization/AddLanguageDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import LanguageActionDialog from '@/components/localization/LanguageActionDialog.vue'
import LanguageRows from '@/components/localization/LanguageRows.vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import RetryAlert from '@/components/RetryAlert.vue'
import SocialLocaleDialog from '@/components/localization/SocialLocaleDialog.vue'
import { useSiteLanguagesPage } from '@/composables/useSiteLanguagesPage'
import { socialLocaleLabel } from '@/lib/languageCopy'
import { baseLanguage, derivedSocialLocale } from '@/lib/siteLanguages'
import { usePermissionsStore } from '@/stores/permissions'
import { useSessionStore } from '@/stores/session'
const SKELETON_ROWS = ['opacity-100', 'opacity-88', 'opacity-76', 'opacity-64']
const route = useRoute()
const session = useSessionStore()
const permissions = usePermissionsStore()
const siteKey = computed(() => String(route.params.key ?? ''))
const page = useSiteLanguagesPage(toRef(siteKey))
const { loadState, data, dialog, banner, failure, saving, name } = page
const sessionReady = computed(() => session.status === 'ready')
const owner = computed(() => permissions.can('settings.manage_sites'))
const siteName = computed(() => page.siteName.value ?? siteKey.value)
const needsSetup = computed(() => data.value?.needs_setup === true)
const ready = computed(() => loadState.value === 'ready' && data.value !== null)
const rows = computed(() => data.value?.languages ?? [])
const single = computed(() => rows.value.length === 1 && rows.value[0]!.enabled)
const setupPath = computed(
() => `/settings/sites/${encodeURIComponent(siteKey.value)}/languages/setup`,
)
const defaultName = computed(() => name(data.value?.default ?? ''))
const dialogLanguage = computed(() => {
const current = dialog.value
return current && 'lang' in current ? page.languageOf(current.lang) : undefined
})
onMounted(page.load)
watch(siteKey, page.load)
const breadcrumb = computed(() => [
{ label: t('Settings'), to: '/settings' },
{ label: t('Sites'), to: '/settings/sites' },
{ label: siteName.value },
{ label: t('Languages') },
])
</script>
<template>
<DashboardShell
compact-padding
:mobile-title="t('{0} · Languages', [siteName])"
back-to="/settings/sites"
:back-label="t('Back to Sites')"
no-current-nav
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div v-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 md:flex-row md:items-start">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Languages') }}</h1>
<p class="text-pretty text-toned">
{{
t(
'Content languages for {0}. Each article version is written in one language.',
[siteName],
)
}}
</p>
</div>
<LocaleButton
v-if="owner && ready && !needsSetup"
kind="primary"
icon="i-lucide-plus"
class="max-md:self-start"
@click="page.openDialog({ kind: 'add' })"
>
{{ t('Add language') }}
</LocaleButton>
</div>
<div
v-if="loadState === 'loading'"
class="flex flex-col gap-2.5"
role="status"
:aria-label="t('Loading')"
>
<div
v-for="(fade, index) in SKELETON_ROWS"
:key="index"
class="h-11 bg-elevated max-md:h-14"
:class="fade"
/>
</div>
<RetryAlert
v-else-if="loadState === 'error'"
:title="t('Couldn\'t load languages')"
:description="t('Check your connection and try again.')"
@retry="page.load"
/>
<template v-else-if="ready && data">
<LocaleAlert
v-if="!owner"
tone="neutral"
icon="i-lucide-lock"
:title="t('Only owners can change languages')"
:description="
t(
'You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.',
)
"
/>
<LocaleAlert
v-if="needsSetup"
tone="info"
icon="i-lucide-info"
:title="t('Set up languages')"
:description="
t(
'Existing articles don\'t have a language yet. Confirm or change the language for each site.',
)
"
>
<template v-if="owner" #actions>
<LocaleButton small :to="setupPath">{{
t('Set up languages')
}}</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-if="banner?.kind === 'disableDefault'"
tone="warning"
icon="i-lucide-triangle-alert"
:title="t('{0} is the default', [name(banner.lang)])"
:description="
t('Choose another default first, then disable {0}.', [name(banner.lang)])
"
/>
<LocaleAlert
v-else-if="banner?.kind === 'disableLast'"
tone="warning"
icon="i-lucide-triangle-alert"
:title="t('At least one language must stay enabled')"
:description="t('{0} needs a language for new articles.', [siteName])"
/>
<LocaleAlert
v-else-if="banner?.kind === 'stale'"
tone="error"
icon="i-lucide-circle-x"
:title="t('Languages changed. Reload.')"
:description="
t(
'Another owner changed the languages for {0} while this page was open. Reload to see the current list. Your change wasn\'t applied.',
[siteName],
)
"
>
<template #actions>
<LocaleButton small @click="page.reload">{{ t('Reload') }}</LocaleButton>
</template>
</LocaleAlert>
<LocaleAlert
v-else-if="banner?.kind === 'failed'"
tone="error"
icon="i-lucide-circle-x"
:title="
banner.action === 'default'
? t('Couldn\'t change the default language')
: t('Couldn\'t remove {0}', [name(banner.lang)])
"
:description="
banner.action === 'default'
? t('Nothing changed. {0} is still the default. Try again.', [
defaultName,
])
: t('Nothing changed. {0} is still listed. Try again.', [
name(banner.lang),
])
"
>
<template #actions>
<LocaleButton small @click="page.retryBanner">{{
t('Try again')
}}</LocaleButton>
</template>
</LocaleAlert>
<div
v-if="single"
class="flex flex-col gap-3 border border-default bg-default p-4"
data-testid="single-language"
>
<span class="font-semibold text-highlighted">{{
t('{0} uses one language', [siteName])
}}</span>
<span class="text-toned">{{
t(
'New articles are written in {0}. Add a language to write translations and publish them separately.',
[name(rows[0]!.lang)],
)
}}</span>
<div class="flex flex-wrap items-center gap-2.5">
<LocaleBadge tone="info" icon="i-lucide-star">{{
t('Default')
}}</LocaleBadge>
<span class="font-medium text-highlighted">{{ name(rows[0]!.lang) }}</span>
<span class="font-mono text-xs/5 text-muted">{{ rows[0]!.lang }}</span>
<span class="text-xs/5 text-muted">{{ t('Social locale') }}</span>
<span class="font-mono text-xs text-highlighted">{{
socialLocaleLabel(rows[0]!)
}}</span>
</div>
</div>
<LanguageRows
v-else-if="rows.length > 0"
:languages="rows"
:owner="owner"
:name-of="name"
@edit-social="(lang) => page.openDialog({ kind: 'social', lang })"
@reenable="(lang) => page.openDialog({ kind: 'reenable', lang })"
@make-default="(lang) => page.openDialog({ kind: 'default', lang })"
@disable="page.openDisable"
@remove="page.openRemove"
/>
<LocaleAlert
v-if="single && rows[0]!.lang.includes('-')"
tone="info"
icon="i-lucide-info"
:title="t('Regional language kept: {0}', [rows[0]!.lang])"
:description="
t(
'Setup kept the full site locale, so the default content language is {0}, not plain {1}. The site locale still formats dates and numbers. Social locale: {2}, taken from the language code.',
[
name(rows[0]!.lang),
name(baseLanguage(rows[0]!.lang)),
socialLocaleLabel({
...rows[0]!,
social_locale_state: derivedSocialLocale(rows[0]!.lang)
? 'derived'
: 'not_set',
}),
],
)
"
/>
<p
v-if="!single && owner && rows.length > 0"
class="text-xs/5 text-pretty text-muted"
>
{{
t(
'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out.',
)
}}
</p>
</template>
</div>
<AddLanguageDialog
v-if="dialog?.kind === 'add' && data"
:languages="data"
:site-name="siteName"
:pending="saving"
:failure="failure"
:name-of="name"
@close="page.closeDialog"
@confirm="page.addLanguage"
@reload="page.reload"
/>
<LanguageActionDialog
v-else-if="
dialog &&
dialogLanguage &&
(dialog.kind === 'default' ||
dialog.kind === 'disable' ||
dialog.kind === 'reenable' ||
dialog.kind === 'remove' ||
dialog.kind === 'removeBlocked')
"
:kind="dialog.kind"
:language="dialogLanguage"
:name="name(dialog.lang)"
:pending="saving"
:failure="failure"
@close="page.closeDialog"
@reload="page.reload"
@confirm="
dialog.kind === 'default'
? page.makeDefault(dialog.lang)
: dialog.kind === 'disable'
? page.disable(dialog.lang)
: dialog.kind === 'reenable'
? page.reenable(dialog.lang)
: dialog.kind === 'remove'
? page.remove(dialog.lang)
: page.disableInstead(dialog.lang)
"
/>
<SocialLocaleDialog
v-else-if="dialog?.kind === 'social' && dialogLanguage"
:language="dialogLanguage"
:name="name(dialog.lang)"
:pending="saving"
:failure="failure"
@close="page.closeDialog"
@reload="page.reload"
@confirm="(value) => page.saveSocialLocale(dialogLanguage!.lang, value)"
/>
</DashboardShell>
</template>
+18 -1
View File
@@ -2,7 +2,7 @@
import { t, formatNumber } from '@/i18n' import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay } from '@/i18n/presentation' import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
@@ -36,6 +36,7 @@ const INITIAL_SKELETON_ROWS = 3
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site } type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
const route = useRoute() const route = useRoute()
const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
@@ -371,6 +372,10 @@ function deleteHint(site: Site) {
return site.is_default ? copy('defaultSiteBlocked') : undefined return site.is_default ? copy('defaultSiteBlocked') : undefined
} }
function siteSubPath(site: Site, page: 'languages' | 'public-url') {
return `/settings/sites/${encodeURIComponent(site.key)}/${page}`
}
function menuItems(site: Site): DirectoryMenuItem[] { function menuItems(site: Site): DirectoryMenuItem[] {
const items: DirectoryMenuItem[] = [ const items: DirectoryMenuItem[] = [
{ {
@@ -379,6 +384,18 @@ function menuItems(site: Site): DirectoryMenuItem[] {
icon: 'i-lucide-pencil', icon: 'i-lucide-pencil',
onSelect: (trigger) => openEdit(site, trigger), onSelect: (trigger) => openEdit(site, trigger),
}, },
{
key: 'languages',
label: t('Languages'),
icon: 'i-lucide-languages',
onSelect: () => router.push(siteSubPath(site, 'languages')),
},
{
key: 'public-url',
label: t('Public URL'),
icon: 'i-lucide-link',
onSelect: () => router.push(siteSubPath(site, 'public-url')),
},
] ]
if (!site.is_default) { if (!site.is_default) {
items.push({ items.push({
@@ -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('24 results') expect(document.body.textContent).toContain('25 results')
expect(document.body.textContent).toContain( expect(document.body.textContent).toContain(
'17 more endpoints. Keep typing to narrow the list.', '18 more endpoints. Keep typing to narrow the list.',
) )
expect(liveRegion()).toBe('24 results, showing 7') expect(liveRegion()).toBe('25 results, showing 7')
}) })
}) })
@@ -441,7 +441,7 @@ describe('8f Session-only route', () => {
expect(path.classList.contains('font-normal')).toBe(true) expect(path.classList.contains('font-normal')).toBe(true)
expect(route!.innerHTML).toContain('border-dashed') expect(route!.innerHTML).toContain('border-dashed')
expect(section!.getAttribute('aria-label')).toBe( expect(section!.getAttribute('aria-label')).toBe(
'Section Session only. 37 routes, not available to API keys', 'Section Session only. 40 routes, not available to API keys',
) )
expect(section!.querySelector('svg')).not.toBeNull() expect(section!.querySelector('svg')).not.toBeNull()
expect(document.body.textContent).toContain('2 results') expect(document.body.textContent).toContain('2 results')
@@ -181,7 +181,7 @@ describe('Default, every card collapsed', () => {
['entries', 'Entries', '5 routes', 5], ['entries', 'Entries', '5 routes', 5],
['builtin', 'Built-in types', '2 routes', 2], ['builtin', 'Built-in types', '2 routes', 2],
['media', 'Media', '11 routes', 11], ['media', 'Media', '11 routes', 11],
['sites', 'Sites and authors', '2 routes', 2], ['sites', 'Sites and authors', '3 routes', 3],
['users', 'Users and invites', '6 routes', 6], ['users', 'Users and invites', '6 routes', 6],
['settings', 'Settings', '1 route', 1], ['settings', 'Settings', '1 route', 1],
])('titles the %s group %s with %s', async (id, title, count, cards) => { ])('titles the %s group %s with %s', async (id, title, count, cards) => {
@@ -505,17 +505,17 @@ describe('Authentication', () => {
}) })
describe('Session only', () => { describe('Session only', () => {
it('renders the banner, 37 rows without expand controls and the 403 chips', async () => { it('renders the banner, 40 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration() const wrapper = await mountIntegration()
const session = section(wrapper, 'session') const session = section(wrapper, 'session')
expect(session.get('h2').text()).toBe('Session only') expect(session.get('h2').text()).toBe('Session only')
expect(session.text()).toContain('37 routes, not available to API keys') expect(session.text()).toContain('40 routes, not available to API keys')
expect(session.text()).toContain( expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."', 'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
) )
const rows = session.findAll('[data-session-row]') const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(37) expect(rows).toHaveLength(40)
expect(session.findAll('[data-session-row] button')).toHaveLength(0) expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0) expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
@@ -535,10 +535,13 @@ describe('Session only', () => {
'PATCH /v1/site/{id}', 'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default', 'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}', 'DELETE /v1/site/{id}',
'PUT /v1/site/{key}/languages',
'POST /v1/author/', 'POST /v1/author/',
'PATCH /v1/author/{id}', 'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}', 'DELETE /v1/author/{id}',
'POST /v1/author/me', 'POST /v1/author/me',
'GET /v1/language/setup',
'POST /v1/language/setup',
'GET /v1/image-variants/', 'GET /v1/image-variants/',
'POST /v1/image-variants/', 'POST /v1/image-variants/',
'PUT /v1/image-variants/{name}', 'PUT /v1/image-variants/{name}',
@@ -0,0 +1,327 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import USelect from '@nuxt/ui/components/Select.vue'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import { initializeLocale } from '@/i18n'
import { AVAILABLE, SITES } from './localizationData'
import {
WRITE_FAILS,
callsTo,
control,
deferred,
failWith,
kit,
mountRoute,
press,
settle,
text,
unmountRoute,
type Route,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
const JOURNAL = {
key: 'journal',
name: 'Journal',
locale: 'en',
article_count: 8,
suggestion: { lang: 'en', substituted: false },
}
const NOTES = { key: 'notes', name: 'Notes', locale: null, article_count: 3, suggestion: null }
const ARCHIVE = {
key: 'archive',
name: 'Archive',
locale: 'pt-BR',
article_count: 5,
suggestion: { lang: 'pt-BR', substituted: false },
}
const GALLERY = {
key: 'gallery',
name: 'Gallery',
locale: 'en-GB',
article_count: 0,
suggestion: { lang: 'en-GB', substituted: false },
}
const BELGIAN = {
key: 'belgium',
name: 'Belgium',
locale: 'fr-BE',
article_count: 2,
suggestion: { lang: 'fr', substituted: true },
}
const POST = 'POST /v1/language/setup'
function pending(...sites: unknown[]) {
return { sites, available: AVAILABLE }
}
function open(setup: unknown, extra: Record<string, Route> = {}, role = 'owner', key = 'journal') {
return mountRoute(`/settings/sites/${key}/languages/setup`, {
role,
routes: { 'GET /v1/site/': SITES, 'GET /v1/language/setup': setup, ...extra },
})
}
async function choose(siteKey: string, label: string, tag: string) {
const selects = kit.wrapper!.findAllComponents(LocaleSelect)
const target = selects.find((select) => select.props('ariaLabel') === label)
if (!target) {
throw new Error(`No select ${label} for ${siteKey}`)
}
target.vm.$emit('update:modelValue', tag)
await settle()
}
function confirmButton() {
return control(/^Confirm and assign to/) as HTMLButtonElement
}
describe('loc.l2.ready: Ready to assign', () => {
beforeEach(async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE))
})
it('shows the heading, the info alert and one row per pending site', () => {
const page = text()
expect(page).toContain('Set article languages')
expect(page).toContain(
"Existing articles don't have a language yet. Confirm or change the language for each site.",
)
expect(page).toContain("This labels articles. It doesn't translate them.")
expect(page).toContain(
"Each existing article becomes the only version of its own article group. Each site's language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.",
)
expect(page).toContain('8 articles · Site locale en')
expect(page).toContain('3 articles · No site locale')
expect(page).toContain('5 articles · Site locale pt-BR')
expect(page).toContain('Seeded from site locale en. Confirm or change.')
expect(page).toContain('Seeded from site locale pt-BR. Confirm or change.')
expect(page).toContain('Required before you can apply.')
})
it('seeds Journal and Archive and leaves Notes empty', () => {
const selects = kit.wrapper!.findAllComponents(USelect)
const values = selects.map((select) => select.props('modelValue'))
expect(values).toEqual(['en', undefined, 'pt-BR'])
})
it('keeps the confirm button disabled until every row has a language, with the total', async () => {
expect(confirmButton().textContent?.trim()).toBe('Confirm and assign to 16 articles')
expect(confirmButton().disabled).toBe(true)
expect(text()).toContain('Counts are articles in 3 sites.')
await choose('notes', 'Language for Notes', 'en')
expect(confirmButton().disabled).toBe(false)
})
})
describe('The page sends one request for all rows', () => {
it('posts Journal en, Notes en, Archive pt-BR and shows Done with the count', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), {
[POST]: { assigned: 16, skipped: [], sites: [] },
})
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(callsTo('POST', '/v1/language/setup')).toHaveLength(1)
expect(callsTo('POST', '/v1/language/setup')[0]!.body).toEqual({
sites: [
{ key: 'journal', lang: 'en' },
{ key: 'notes', lang: 'en' },
{ key: 'archive', lang: 'pt-BR' },
],
})
})
})
describe('loc.l2.noLocale: No-locale site', () => {
it('shows the error alert and a disabled confirm button', async () => {
await open(pending(NOTES))
expect(text()).toContain('Choose a language for Notes')
expect(text()).toContain(
'Notes has no site locale, so there is nothing to start from. Choose the language its articles are written in.',
)
expect(confirmButton().disabled).toBe(true)
})
})
describe('loc.l2.empty: Site with no articles', () => {
it('shows the page as Languages with the card, the seeded field and a Save button', async () => {
await open(pending(GALLERY), {}, 'owner', 'gallery')
const page = text()
expect(page).toContain('Languages')
expect(page).toContain('Content languages for Gallery.')
expect(page).toContain('Confirm the default language')
expect(page).toContain(
'Gallery has no articles yet. New articles will be written in this language.',
)
expect(page).toContain('Default content language')
expect(page).toContain(
'Seeded from site locale en-GB. The full locale is kept. Confirm or change it.',
)
expect(kit.wrapper!.findComponent(USelect).props('modelValue')).toBe('en-GB')
expect((control('Save') as HTMLButtonElement).disabled).toBe(false)
})
it('posts the single site on Save', async () => {
await open(
pending(GALLERY),
{ [POST]: { assigned: 0, skipped: [], sites: [] } },
'owner',
'gallery',
)
await press('Save')
expect(callsTo('POST', '/v1/language/setup')[0]!.body).toEqual({
sites: [{ key: 'gallery', lang: 'en-GB' }],
})
})
})
describe('loc.l2.applying: Applying', () => {
it('shows the busy button and the alert while the POST is in flight', async () => {
const inflight = deferred<unknown>()
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: () => inflight.promise })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Assigning languages')
expect(text()).toContain(
"Articles created while this runs get the site's default language.",
)
const busy = control(/^Assigning…/) as HTMLButtonElement
expect(busy.disabled).toBe(true)
inflight.resolve({ assigned: 16, skipped: [], sites: [] })
})
})
describe('loc.l2.inProgress: Setup already running', () => {
beforeEach(async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: failWith(409, 'setup_in_progress') })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
})
it('explains it, makes the rows read-only and disables confirm', () => {
expect(text()).toContain('Setup is already running')
expect(text()).toContain(
"Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site's default language. Starting it again is safe: sites that already have a language are skipped.",
)
expect(kit.wrapper!.findAllComponents(USelect)).toHaveLength(0)
expect(confirmButton().disabled).toBe(true)
})
it('repeats GET /v1/language/setup on Check again', async () => {
await press('Check again')
expect(callsTo('GET', '/v1/language/setup')).toHaveLength(2)
})
})
describe('loc.l2.done: Done', () => {
it('shows the success alert with the count and Open Articles, and no confirm button', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), {
[POST]: { assigned: 16, skipped: [], sites: [] },
})
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Languages assigned')
expect(text()).toContain(
'16 articles now have a language. Nothing was translated, and no articles were grouped.',
)
const link = Array.from(document.querySelectorAll('a')).find(
(anchor) => anchor.textContent?.trim() === 'Open Articles',
)
expect(link?.getAttribute('href')).toBe('/article')
expect(control(/^Confirm and assign to/)).toBeUndefined()
})
})
describe('loc.l2.fail: Failed', () => {
it('keeps the choices and retries with Try again', async () => {
await open(pending(JOURNAL, NOTES, ARCHIVE), { [POST]: WRITE_FAILS })
await choose('notes', 'Language for Notes', 'en')
await press(/^Confirm and assign to/)
expect(text()).toContain('Nothing was changed')
expect(text()).toContain(
'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.',
)
expect(kit.wrapper!.findAllComponents(USelect).map((s) => s.props('modelValue'))).toEqual([
'en',
'en',
'pt-BR',
])
expect(confirmButton().disabled).toBe(false)
await press('Try again')
expect(callsTo('POST', '/v1/language/setup')).toHaveLength(2)
})
})
describe('Nothing pending', () => {
it('shows Done without an article count', async () => {
await open(pending())
expect(text()).toContain('Languages assigned')
expect(text()).not.toMatch(/\d+ articles? now has?\b/)
expect(text()).toContain('Nothing was translated, and no articles were grouped.')
})
it('shows the same once Check again finds nothing pending', async () => {
let reads = 0
await open(pending(JOURNAL), {
'GET /v1/language/setup': () => (reads++ === 0 ? pending(JOURNAL) : pending()),
[POST]: failWith(409, 'setup_in_progress'),
})
await press(/^Confirm and assign to/)
await press('Check again')
expect(text()).toContain('Languages assigned')
})
})
describe('Substituted seed', () => {
it('shows the seeded broader tag with the broader wording', async () => {
await open(pending(BELGIAN))
expect(kit.wrapper!.findComponent(USelect).props('modelValue')).toBe('fr')
expect(text()).toContain(
'Only the broader language French (fr) is supported. Pages and the API will say fr, not fr-BE.',
)
})
})
describe('Unknown or already complete :key', () => {
it('lists the pending sites and links back to the Languages page of a known key', async () => {
await open(pending(JOURNAL, NOTES), {}, 'owner', 'archive')
expect(text()).toContain('Journal')
const back = document.querySelector('a[href="/settings/sites/archive/languages"]')
expect(back).not.toBeNull()
})
it('falls back to Settings, Sites for an unknown key', async () => {
await open(pending(JOURNAL), {}, 'owner', 'nowhere')
expect(document.querySelector('a[href="/settings/sites/nowhere/languages"]')).toBeNull()
expect(document.querySelector('a[href="/settings/sites"]')).not.toBeNull()
})
})
describe('Route guard', () => {
it('keeps a reader out of the page and sends nothing', async () => {
await open(pending(JOURNAL), {}, 'viewer')
expect(text()).toContain("You don't have access to this page")
expect(callsTo('GET', '/v1/language/setup')).toHaveLength(0)
})
})
@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { SITES, siteRow } from './localizationData'
import {
callsTo,
control,
failWith,
mountRoute,
press,
settle,
text,
toastText,
typeInto,
unmountRoute,
type Route,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
const TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
const SLUG = 'comment-nous-chiffrons-une-renovation'
function sites(pattern: string | null) {
return [{ ...SITES[0]!, public_url_pattern: pattern }, ...SITES.slice(1)]
}
const ARTICLES = {
items: [{ id: 14, title: TITLE, slug: SLUG, lang: 'fr', body: '', is_online: true }],
total: 1,
page: 1,
size: 1,
counts: { all: 1, online: 1, draft: 0 },
}
const NO_ARTICLES = { ...ARTICLES, items: [], total: 0 }
async function open(
pattern: string | null,
extra: Record<string, Route> = {},
role = 'owner',
articles: unknown = ARTICLES,
) {
await mountRoute('/settings/sites/journal/public-url', {
role,
routes: {
'GET /v1/site/': sites(pattern),
'GET /v1/article/': articles,
...extra,
},
})
}
function input() {
return document.querySelector<HTMLInputElement>('input')!
}
function save() {
return control('Save') as HTMLButtonElement
}
describe('loc.l1b.set: Set', () => {
it('shows the saved pattern, the help, the preview and Save and Cancel', async () => {
await open(PATTERN)
const page = text()
expect(page).toContain('Public URL')
expect(page).toContain(
'Where Journal articles appear on your website. Used for canonical and alternate links.',
)
expect(page).toContain('Public URL pattern')
expect(input().value).toBe(PATTERN)
expect(page).toContain(
'Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.',
)
expect(page).toContain(`Preview with "${TITLE}" in French`)
expect(page).toContain(`https://journal.example.com/fr/${SLUG}`)
expect(save()).toBeTruthy()
expect(control('Cancel')).toBeTruthy()
expect(callsTo('GET', '/v1/article/')[0]!.query.get('site')).toBe('journal')
})
})
describe('loc.l1b.editing: Editing', () => {
it('makes the preview follow the field on each change', async () => {
await open(PATTERN)
await typeInto(input(), 'https://journal.example.com/{lang}/articles/{slug}')
expect(text()).toContain(`https://journal.example.com/fr/articles/${SLUG}`)
await typeInto(input(), 'https://journal.example.com/{slug}')
expect(text()).toContain(`https://journal.example.com/${SLUG}`)
expect(text()).not.toContain('/fr/')
})
})
describe.each([
[
'loc.l1b.unknown: Unknown placeholder',
'https://journal.example.com/{language}/{slug}',
'Unknown placeholder {language}. Use {lang} and {slug}.',
],
[
'loc.l1b.scheme: Bad scheme',
'ftp://journal.example.com/{lang}/{slug}',
'Start with https:// or http://.',
],
[
'loc.l1b.encoded: Not encoded',
'https://journal.example.com/my pages/{lang}/{slug}',
'Remove spaces and characters that need encoding. Use letters, digits, - and _.',
],
[
'loc.l1b.noSlug: Missing {slug}',
'https://journal.example.com/{lang}',
'Add {slug} so each article has its own address.',
],
])('%s', (_state, typed, message) => {
it('shows the error, hides the preview and disables Save', async () => {
await open(PATTERN)
await typeInto(input(), typed)
expect(text()).toContain(message)
expect(document.querySelector('[data-testid="preview"]')).toBeNull()
expect(save().disabled).toBe(true)
save().click()
await settle()
expect(callsTo('PATCH', '/v1/site/1')).toHaveLength(0)
})
})
describe('Client and server agree', () => {
it.each([
['invalid_scheme', 'Start with https:// or http://.'],
['invalid_host', 'Start with https:// or http://.'],
['missing_slug', 'Add {slug} so each article has its own address.'],
['unknown_placeholder', 'Unknown placeholder. Use {lang} and {slug}.'],
[
'not_encoded',
'Remove spaces and characters that need encoding. Use letters, digits, - and _.',
],
])('maps the server code %s to its message', async (code, message) => {
await open(PATTERN, { 'PATCH /v1/site/1': failWith(422, code) })
await typeInto(input(), 'https://journal.example.com/ok/{slug}')
save().click()
await settle()
expect(text()).toContain(message)
expect(save().disabled).toBe(true)
})
})
describe('loc.l1b.saved: Saved', () => {
it('sends the trimmed pattern, shows the toast and keeps the saved value', async () => {
const updated = { ...sites(PATTERN)[0]!, public_url_pattern: PATTERN }
await open(null, { 'PATCH /v1/site/1': updated })
await typeInto(input(), ` ${PATTERN} `)
await press('Save')
expect(callsTo('PATCH', '/v1/site/1')[0]!.body).toEqual({ public_url_pattern: PATTERN })
expect(toastText()).toContain('Public URL saved')
expect(toastText()).toContain('Journal pages now use this pattern.')
expect(input().value).toBe(PATTERN)
})
})
describe('loc.l1b.unset: Unset', () => {
it('shows the info alert, the placeholder, no preview and a disabled Save', async () => {
await open(null)
const page = text()
expect(page).toContain('Public URL not configured')
expect(page).toContain(
'Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.',
)
expect(input().placeholder).toBe('https://example.com/{lang}/{slug}')
expect(document.querySelector('[data-testid="preview"]')).toBeNull()
expect(save().disabled).toBe(true)
})
})
describe('loc.l1b.viewOnly: Read-only view', () => {
it('shows the pattern read-only with no Save or Cancel', async () => {
await open(PATTERN, {}, 'viewer')
expect(text()).toContain('Only owners can change this')
expect(text()).toContain('You can read the pattern. Ask an owner to change it.')
expect(input().readOnly).toBe(true)
expect(input().value).toBe(PATTERN)
expect(control('Save')).toBeUndefined()
expect(control('Cancel')).toBeUndefined()
})
})
describe('Clearing the field', () => {
it('sends null and shows the Unset state', async () => {
const cleared = { ...sites(PATTERN)[0]!, public_url_pattern: null }
await open(PATTERN, { 'PATCH /v1/site/1': cleared })
await typeInto(input(), '')
await press('Save')
expect(callsTo('PATCH', '/v1/site/1')[0]!.body).toEqual({ public_url_pattern: null })
expect(input().value).toBe('')
expect(text()).toContain('Public URL not configured')
})
})
describe('Cancel', () => {
it('returns the field to the saved value and sends nothing', async () => {
await open(PATTERN)
await typeInto(input(), 'https://other.example.com/{slug}')
await press('Cancel')
expect(input().value).toBe(PATTERN)
expect(callsTo('PATCH', '/v1/site/1')).toHaveLength(0)
})
})
describe('Preview of a site without articles', () => {
it('shows no preview line', async () => {
await open(PATTERN, {}, 'owner', NO_ARTICLES)
expect(document.querySelector('[data-testid="preview"]')).toBeNull()
expect(input().value).toBe(PATTERN)
})
it('uses the default language of the site when the article has none yet', async () => {
await open(
PATTERN,
{
'GET /v1/site/journal/languages': { default: 'en', languages: [], available: [] },
},
'owner',
{ ...ARTICLES, items: [{ ...ARTICLES.items[0]!, lang: '' }] },
)
expect(text()).toContain(`https://journal.example.com/en/${SLUG}`)
})
})
describe('Failures with no frame', () => {
it('reuses the Languages alert copy when the load fails', async () => {
await mountRoute('/settings/sites/journal/public-url', {
routes: { 'GET /v1/site/': failWith(500, 'Internal server error') },
})
expect(text()).toContain("Couldn't load languages")
expect(text()).toContain('Check your connection and try again.')
})
it('reuses the Languages alert copy when the save fails with a server error', async () => {
await open(PATTERN, { 'PATCH /v1/site/1': failWith(500, 'Internal server error') })
await typeInto(input(), 'https://journal.example.com/{slug}')
await press('Save')
expect(text()).toContain("Couldn't save languages")
expect(text()).toContain('Nothing changed. Your selection is kept. Try again.')
expect(input().value).toBe('https://journal.example.com/{slug}')
})
it('shows the load failure for a site that does not exist', async () => {
await mountRoute('/settings/sites/nowhere/public-url', {
routes: { 'GET /v1/site/': [siteRow(9, 'other', 'Other')] },
})
expect(text()).toContain("Couldn't load languages")
})
})
@@ -106,7 +106,7 @@ describe('Search and sharing group', () => {
.map((item) => item.props('endpoint').path), .map((item) => item.props('endpoint').path),
).toContain('/v1/article/{id}/seo/check') ).toContain('/v1/article/{id}/seo/check')
expect(section(wrapper, 'sites').text()).not.toContain('sitemap') expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
expect(section(wrapper, 'sites').text()).toContain('2 routes') expect(section(wrapper, 'sites').text()).toContain('3 routes')
}) })
it('describes the resolved SEO object and shows the real error rows', async () => { it('describes the resolved SEO object and shows the real error rows', async () => {
@@ -0,0 +1,459 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import USelect from '@nuxt/ui/components/Select.vue'
import { initializeLocale } from '@/i18n'
import { ATELIER, JOURNAL, JOURNAL_ROWS, SITES, languagesBody } from './localizationData'
import {
WRITE_FAILS,
callsTo,
chooseAction,
control,
dialogBox,
dialogText,
failWith,
kit,
mountRoute,
press,
settle,
text,
toastText,
typeInto,
unmountRoute,
type Route,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
const PUT = 'PUT /v1/site/journal/languages'
const PATH = '/v1/site/journal/languages'
function journal(extra: Record<string, Route> = {}, data: unknown = JOURNAL) {
return mountRoute('/settings/sites/journal/languages', {
routes: { 'GET /v1/site/': SITES, 'GET /v1/site/journal/languages': data, ...extra },
})
}
async function chooseLanguage(tag: string) {
kit.wrapper!.findComponent(USelect).vm.$emit('update:modelValue', tag)
await settle()
}
function sentBody(index = 0) {
return callsTo('PUT', PATH)[index]!.body as {
revision: string
default: string
languages: { lang: string; enabled: boolean; social_locale: string | null }[]
}
}
describe('loc.l1.add: Add a language', () => {
it('opens the dialog with a read-only code and social locale for the chosen language', async () => {
await journal()
await press('Add language')
expect(dialogText()).toContain('Add a language')
expect(dialogText()).toContain(
'Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries.',
)
await chooseLanguage('pt-BR')
const box = dialogText()
expect(box).toContain('Language code')
expect(box).toContain('Used in the API and in page language metadata.')
expect(box).toContain('Social locale')
const readonly = Array.from(
dialogBox()!.querySelectorAll<HTMLInputElement>('input[readonly]'),
)
expect(readonly.map((input) => input.value)).toEqual(['pt-BR', 'Derived: pt_BR'])
expect(control('Cancel', dialogBox()!)).toBeTruthy()
expect(control('Add language', dialogBox()!)).toBeTruthy()
})
it('lists a disabled language as "Disabled — re-enable instead" and refuses it', async () => {
await journal()
await press('Add language')
const items = kit.wrapper!.findComponent(USelect).props('items') as {
value: string
label: string
disabled?: boolean
}[]
const spanish = items.find((item) => item.value === 'es')!
expect(spanish.label).toBe('Spanish · Disabled — re-enable instead')
expect(spanish.disabled).toBe(true)
expect(items.find((item) => item.value === 'fr')!.disabled).toBe(false)
})
})
describe('loc.l1.saved: Saved', () => {
it('sends the full state plus the new row, closes the dialog and shows the toast', async () => {
const added = languagesBody('journal-2', 'en', [
...JOURNAL_ROWS,
{ lang: 'pt-BR', versions: 0, published: 0, derived: true },
])
await journal({ [PUT]: added })
await press('Add language')
await chooseLanguage('pt-BR')
await press('Add language', dialogBox()!)
expect(sentBody()).toEqual({
revision: 'journal-1',
default: 'en',
languages: [
...JOURNAL_ROWS.map((row) => ({
lang: row.lang,
enabled: row.enabled ?? true,
social_locale: row.social ?? null,
})),
{ lang: 'pt-BR', enabled: true, social_locale: null },
],
})
expect(dialogBox()).toBeNull()
expect(toastText()).toContain('Portuguese (Brazil) added')
expect(toastText()).toContain('Writers can now create Portuguese (Brazil) versions.')
expect(text()).toContain('Portuguese (Brazil)')
})
})
describe('loc.l1.saveFail: Add failed', () => {
it('keeps the dialog and the selection and offers Try again', async () => {
await journal({ [PUT]: WRITE_FAILS })
await press('Add language')
await chooseLanguage('pt-BR')
await press('Add language', dialogBox()!)
expect(dialogBox()).not.toBeNull()
expect(dialogText()).toContain("Couldn't save languages")
expect(dialogText()).toContain('Nothing changed. Your selection is kept. Try again.')
expect(
Array.from(dialogBox()!.querySelectorAll<HTMLInputElement>('input[readonly]')).map(
(input) => input.value,
),
).toContain('pt-BR')
expect(control('Try again', dialogBox()!)).toBeTruthy()
await press('Try again', dialogBox()!)
expect(callsTo('PUT', PATH)).toHaveLength(2)
})
})
describe('loc.l1.adddup: Duplicate language', () => {
it('shows the field error, disables Add language and sends nothing', async () => {
await journal()
await press('Add language')
await chooseLanguage('fr')
expect(dialogText()).toContain('French is already enabled for Journal.')
const add = control('Add language', dialogBox()!) as HTMLButtonElement
expect(add.disabled).toBe(true)
add.click()
await settle()
expect(callsTo('PUT', PATH)).toHaveLength(0)
})
})
describe('loc.l1.broader: Only a broader tag supported', () => {
it('explains the substitution and sends pt only once the box is checked', async () => {
await journal({ [PUT]: JOURNAL })
await press('Add language')
await chooseLanguage('pt-PT')
expect(dialogText()).toContain("Portuguese (Portugal) isn't available")
expect(dialogText()).toContain(
"Only the broader language Portuguese (pt) is supported. Pages and the API will say pt, not pt-PT. Articles written in this language can't be told apart from other Portuguese.",
)
expect(dialogText()).toContain('Add Portuguese (pt) instead')
const add = control('Add Portuguese', dialogBox()!) as HTMLButtonElement
expect(add.disabled).toBe(true)
dialogBox()!.querySelector<HTMLElement>('[role="checkbox"]')!.click()
await settle()
expect((control('Add Portuguese', dialogBox()!) as HTMLButtonElement).disabled).toBe(false)
await press('Add Portuguese', dialogBox()!)
expect(sentBody().languages.at(-1)).toEqual({
lang: 'pt',
enabled: true,
social_locale: null,
})
})
})
describe('The row menu', () => {
it('offers Make default, Disable and Remove, and keeps Re-enable as a row button', async () => {
await journal()
await press('Actions for French')
const items = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((item) =>
item.textContent?.trim(),
)
expect(items).toEqual(['Make default', 'Disable', 'Remove'])
})
it('offers only Remove on a disabled language', async () => {
await journal()
await press('Actions for Spanish')
const items = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((item) =>
item.textContent?.trim(),
)
expect(items).toEqual(['Remove'])
})
})
describe('loc.l1.default: Make default', () => {
it('asks for confirmation, then sends the new default', async () => {
await journal({ [PUT]: JOURNAL })
await chooseAction('French', 'Make default')
expect(dialogText()).toContain('Make French the default language?')
expect(dialogText()).toContain(
"New articles start in the default language, and it is the first choice for each article's title in the list.",
)
expect(dialogText()).toContain('No content changes')
await press('Make French default', dialogBox()!)
expect(sentBody().default).toBe('fr')
expect(sentBody().revision).toBe('journal-1')
expect(dialogBox()).toBeNull()
})
it('loc.l1.defaultFail: keeps the dialog and shows the failure', async () => {
await journal({ [PUT]: WRITE_FAILS })
await chooseAction('French', 'Make default')
await press('Make French default', dialogBox()!)
expect(dialogText()).toContain("Couldn't change the default language")
expect(dialogText()).toContain('Nothing changed. Try again.')
expect(control('Try again', dialogBox()!)).toBeTruthy()
expect(text()).toContain('Nothing changed. English is still the default. Try again.')
})
})
describe('loc.l1.disable: Disable with content', () => {
it('names the versions and sends enabled false for German', async () => {
await journal({ [PUT]: JOURNAL })
await chooseAction('German', 'Disable')
expect(dialogText()).toContain('Disable German?')
expect(dialogText()).toContain('German is used by 4 versions: 1 draft and 3 published.')
expect(dialogText()).toContain('Nothing is deleted or unpublished')
expect(dialogText()).toContain(
"The 3 published German versions stay online. All 4 versions stay readable and editable. The draft can't be published until you re-enable German. Nobody can create new German versions.",
)
await press('Disable German', dialogBox()!)
expect(sentBody().languages.find((row) => row.lang === 'de')!.enabled).toBe(false)
expect(sentBody().languages.find((row) => row.lang === 'en')!.enabled).toBe(true)
})
})
describe('loc.l1.stale: Languages changed', () => {
it('shows the Reload alert and repeats the GET', async () => {
await journal({ [PUT]: failWith(409, 'languages_changed') })
await chooseAction('German', 'Disable')
await press('Disable German', dialogBox()!)
expect(text()).toContain('Languages changed. Reload.')
expect(text()).toContain(
"Another owner changed the languages for Journal while this page was open. Reload to see the current list. Your change wasn't applied.",
)
expect(dialogText()).toContain("Your change wasn't applied.")
await press('Reload', dialogBox()!)
expect(callsTo('GET', PATH)).toHaveLength(2)
expect(dialogBox()).toBeNull()
expect(text()).not.toContain('Languages changed. Reload.')
})
})
describe('Disable is blocked without a request', () => {
it('loc.lang.disableDefault: warns that English is the default', async () => {
await journal()
await chooseAction('English', 'Disable')
expect(text()).toContain('English is the default')
expect(text()).toContain('Choose another default first, then disable English.')
expect(dialogBox()).toBeNull()
expect(callsTo('PUT', PATH)).toHaveLength(0)
})
it('loc.lang.disableLast: warns that one language must stay enabled', async () => {
const solo = languagesBody('journal-solo', 'en', [
{ lang: 'en', versions: 7, published: 6 },
{ lang: 'fr', enabled: false, versions: 4, published: 2 },
])
await journal({}, solo)
await chooseAction('English', 'Disable')
expect(text()).toContain('At least one language must stay enabled')
expect(text()).toContain('Journal needs a language for new articles.')
expect(callsTo('PUT', PATH)).toHaveLength(0)
})
})
describe('loc.l1.reenable: Re-enable', () => {
it('asks first, then enables Spanish and shows the toast', async () => {
await journal({ [PUT]: JOURNAL })
await press('Re-enable', document.querySelector('[data-testid="language-row-es"]')!)
expect(dialogText()).toContain('Re-enable Spanish?')
expect(dialogText()).toContain(
'Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes.',
)
await press('Re-enable Spanish', dialogBox()!)
expect(sentBody().languages.find((row) => row.lang === 'es')!.enabled).toBe(true)
expect(toastText()).toContain('Spanish re-enabled')
expect(toastText()).toContain(
'Writers can create Spanish versions again. Drafts can be published.',
)
})
it('loc.l1.reenableFail: keeps the dialog and says Spanish is still disabled', async () => {
await journal({ [PUT]: WRITE_FAILS })
await press('Re-enable', document.querySelector('[data-testid="language-row-es"]')!)
await press('Re-enable Spanish', dialogBox()!)
expect(dialogText()).toContain("Couldn't re-enable Spanish")
expect(dialogText()).toContain('Nothing changed. Spanish is still disabled. Try again.')
expect(control('Try again', dialogBox()!)).toBeTruthy()
})
})
describe('loc.l1.remove: Remove (no content)', () => {
it('removes Italian after a confirmation with a danger button', async () => {
await journal({ [PUT]: JOURNAL })
await chooseAction('Italian', 'Remove')
expect(dialogText()).toContain('Remove Italian?')
expect(dialogText()).toContain('No article uses Italian. You can add it again later.')
await press('Remove Italian', dialogBox()!)
expect(sentBody().languages.map((row) => row.lang)).not.toContain('it')
expect(sentBody().languages).toHaveLength(5)
})
it('loc.l1.removeFail: keeps the dialog and says Italian is still listed', async () => {
await journal({ [PUT]: WRITE_FAILS })
await chooseAction('Italian', 'Remove')
await press('Remove Italian', dialogBox()!)
expect(dialogText()).toContain("Couldn't remove Italian")
expect(dialogText()).toContain('Nothing changed. Italian is still listed. Try again.')
expect(control('Try again', dialogBox()!)).toBeTruthy()
})
})
describe('loc.l1.removeBlocked: Remove blocked', () => {
it('explains that Spanish is used, offers Close and Disable Spanish, sends nothing', async () => {
await journal()
await chooseAction('Spanish', 'Remove')
expect(dialogText()).toContain("Spanish can't be removed")
expect(dialogText()).toContain(
'Spanish is used by 1 version. Disable the language to keep it, or delete it first.',
)
expect(control('Close', dialogBox()!)).toBeTruthy()
expect(control('Disable Spanish', dialogBox()!)).toBeTruthy()
expect(callsTo('PUT', PATH)).toHaveLength(0)
})
it('shows the same dialog and reloads the list when the server answers language_in_use', async () => {
let reads = 0
const refreshed = languagesBody('journal-2', 'en', [
...JOURNAL_ROWS.slice(0, 5),
{ lang: 'it', enabled: false, versions: 1, published: 0 },
])
await journal({
'GET /v1/site/journal/languages': () => (reads++ === 0 ? JOURNAL : refreshed),
[PUT]: failWith(409, 'language_in_use'),
})
await chooseAction('Italian', 'Remove')
await press('Remove Italian', dialogBox()!)
expect(dialogText()).toContain("Italian can't be removed")
expect(dialogText()).toContain('Italian is used by 1 version.')
expect(callsTo('GET', PATH)).toHaveLength(2)
})
})
describe('loc.l1.socialEdit: Social locale: edit', () => {
beforeEach(async () => {
await mountRoute('/settings/sites/atelier/languages', {
routes: {
'GET /v1/site/': SITES,
'GET /v1/site/atelier/languages': ATELIER,
'PUT /v1/site/atelier/languages': ATELIER,
},
})
await press('Edit social locale for French')
})
function field() {
return dialogBox()!.querySelector<HTMLInputElement>('input')!
}
it('opens the dialog with the current value and the format help', () => {
expect(dialogText()).toContain('Social locale for French')
expect(dialogText()).toContain(
'Used for og:locale on French pages. Leave it empty to omit the tag.',
)
expect(field().value).toBe('fr_FR')
expect(dialogText()).toContain(
'Format: language_REGION, for example fr_FR. Letters only, one underscore.',
)
expect(control('Cancel', dialogBox()!)).toBeTruthy()
expect(control('Save social locale', dialogBox()!)).toBeTruthy()
})
it('loc.l1.socialInvalid: shows the field error and disables the save button', async () => {
await typeInto(field(), 'fr-FR')
expect(dialogText()).toContain('Use letters and one underscore, for example fr_FR.')
expect((control('Save social locale', dialogBox()!) as HTMLButtonElement).disabled).toBe(
true,
)
expect(callsTo('PUT', '/v1/site/atelier/languages')).toHaveLength(0)
})
it('sends the new value with the full state', async () => {
await typeInto(field(), 'fr_CA')
await press('Save social locale', dialogBox()!)
const body = callsTo('PUT', '/v1/site/atelier/languages')[0]!.body as {
languages: { lang: string; social_locale: string | null }[]
}
expect(body.languages.find((row) => row.lang === 'fr')!.social_locale).toBe('fr_CA')
expect(body.languages).toHaveLength(4)
})
it('sends null for an empty field', async () => {
await typeInto(field(), '')
await press('Save social locale', dialogBox()!)
const body = callsTo('PUT', '/v1/site/atelier/languages')[0]!.body as {
languages: { lang: string; social_locale: string | null }[]
}
expect(body.languages.find((row) => row.lang === 'fr')!.social_locale).toBeNull()
})
})
describe('Social locale refused by the server', () => {
it('shows the same field error as the invalid state', async () => {
await mountRoute('/settings/sites/atelier/languages', {
routes: {
'GET /v1/site/': SITES,
'GET /v1/site/atelier/languages': ATELIER,
'PUT /v1/site/atelier/languages': failWith(422, 'invalid_social_locale'),
},
})
await press('Edit social locale for French')
await typeInto(dialogBox()!.querySelector('input')!, 'fr_XX')
await press('Save social locale', dialogBox()!)
expect(dialogText()).toContain('Use letters and one underscore, for example fr_FR.')
})
})
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { ARCHIVE, ATELIER, JOURNAL, SITES, languagesBody } from './localizationData'
import {
callsTo,
control,
deferred,
failWith,
kit,
mountRoute,
press,
settle,
text,
unmountRoute,
} from './localizationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
function routes(key: string, languages: unknown) {
return { 'GET /v1/site/': SITES, [`GET /v1/site/${key}/languages`]: languages }
}
describe('loc.l1.loading: Loading', () => {
it('shows the heading, the subtitle and 4 skeleton rows while the GET is in flight', async () => {
const pending = deferred<unknown>()
await mountRoute('/settings/sites/journal/languages', {
routes: routes('journal', () => pending.promise),
})
expect(text()).toContain('Languages')
expect(text()).toContain(
'Content languages for Journal. Each article version is written in one language.',
)
expect(document.querySelectorAll('[role="status"] > div')).toHaveLength(4)
pending.resolve(JOURNAL)
})
})
describe('loc.l1.error: Load failed', () => {
it('shows the alert and repeats the GET on Try again', async () => {
await mountRoute('/settings/sites/journal/languages', {
routes: routes('journal', failWith(500, 'Internal server error')),
})
expect(text()).toContain("Couldn't load languages")
expect(text()).toContain('Check your connection and try again.')
await press('Try again')
expect(callsTo('GET', '/v1/site/journal/languages')).toHaveLength(2)
})
it('shows the same state for an unknown site key', async () => {
await mountRoute('/settings/sites/nowhere/languages', {
routes: routes('nowhere', failWith(404, 'site_not_found')),
})
expect(text()).toContain("Couldn't load languages")
})
})
describe('loc.l1.single: Single language', () => {
it('shows the card for the one language of Archive', async () => {
await mountRoute('/settings/sites/archive/languages', {
routes: routes('archive', ARCHIVE),
})
const page = text()
expect(page).toContain('Archive uses one language')
expect(page).toContain(
'New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.',
)
expect(page).toContain('Default')
expect(page).toContain('pt-BR')
expect(page).toContain('Social locale')
expect(page).toContain('Derived: pt_BR')
expect(page).toContain('Regional language kept: pt-BR')
expect(control('Add language')).toBeTruthy()
})
})
describe('loc.l1.multi: Multilingual', () => {
beforeEach(async () => {
await mountRoute('/settings/sites/journal/languages', {
routes: routes('journal', JOURNAL),
})
})
it('lists one row per language with its counts and social locale', () => {
const page = text()
expect(page).toContain('Language')
for (const name of ['English', 'French', 'German', 'Arabic', 'Spanish', 'Italian']) {
expect(page).toContain(name)
}
expect(page).toContain('7 versions · 6 published')
expect(page).toContain('4 versions · 2 published')
expect(page).toContain('1 version · 0 published')
expect(page).toContain('Set: fr_FR')
expect(page).toContain('Not set')
})
it('tags the default and the disabled languages', () => {
expect(document.querySelector('[data-testid="language-row-en"]')?.textContent).toContain(
'Default',
)
expect(document.querySelector('[data-testid="language-row-es"]')?.textContent).toContain(
'Disabled',
)
})
it('gives every row its Edit and actions buttons, and a Re-enable button on disabled rows', () => {
expect(control('Edit social locale for French')).toBeTruthy()
expect(control('Actions for French')).toBeTruthy()
expect(
document.querySelectorAll('[data-testid="language-row-es"] button').length,
).toBeGreaterThan(2)
expect(
control('Re-enable', document.querySelector('[data-testid="language-row-es"]')!),
).toBeTruthy()
expect(
control('Re-enable', document.querySelector('[data-testid="language-row-fr"]')!),
).toBeUndefined()
})
it('explains disabling and the three social locale states', () => {
expect(text()).toContain(
'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out.',
)
})
})
describe('loc.l1.readonly: Read-only view', () => {
it('shows the rows and no control for a user without settings.manage_sites', async () => {
await mountRoute('/settings/sites/journal/languages', {
role: 'viewer',
routes: routes('journal', JOURNAL),
})
const page = text()
expect(page).toContain('Only owners can change languages')
expect(page).toContain(
'You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.',
)
expect(page).toContain('7 versions · 6 published')
expect(control('Add language')).toBeUndefined()
expect(control(/^Edit social locale/)).toBeUndefined()
expect(control('Re-enable')).toBeUndefined()
expect(control(/^Actions for/)).toBeUndefined()
})
})
describe('loc.l1.social: Social locale', () => {
it('shows Derived, Set and Not set in the rows of Atelier', async () => {
await mountRoute('/settings/sites/atelier/languages', {
routes: routes('atelier', ATELIER),
})
const page = text()
expect(page).toContain('Derived: en_GB')
expect(page).toContain('Derived: pt_BR')
expect(page).toContain('Set: fr_FR')
expect(page).toContain('Not set')
})
})
describe('Setup banner', () => {
const pending = { ...languagesBody('archive-0', 'pt-BR', []), default: null, needs_setup: true }
it('links an owner to the setup page of the site', async () => {
await mountRoute('/settings/sites/archive/languages', {
routes: routes('archive', pending),
})
const link = control('Set up languages', document.body)
const banner = Array.from(document.querySelectorAll('a')).find(
(anchor) => anchor.textContent?.trim() === 'Set up languages',
)
expect(link).toBeTruthy()
expect(banner?.getAttribute('href')).toBe('/settings/sites/archive/languages/setup')
expect(control('Add language')).toBeUndefined()
})
it('shows no action to a reader', async () => {
await mountRoute('/settings/sites/archive/languages', {
role: 'viewer',
routes: routes('archive', pending),
})
expect(text()).toContain('Set up languages')
expect(document.querySelector('a[href$="/languages/setup"]')).toBeNull()
})
})
describe('Requests', () => {
it('asks for nothing else than the languages and the sites', async () => {
await mountRoute('/settings/sites/journal/languages', {
routes: routes('journal', JOURNAL),
})
await settle()
const made = kit.calls.map((call) => `${call.method} ${call.path}`)
expect(made.filter((call) => !call.endsWith('/v1/content-type')).sort()).toEqual([
'GET /v1/site/',
'GET /v1/site/journal/languages',
])
})
})
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { chooseMenuItem, mountDirectory, openRowMenu, unmountDirectory } from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
function menuLabels() {
return Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((item) =>
item.textContent?.trim(),
)
}
describe('Settings, Sites row actions for localization', () => {
it('offers Languages and Public URL on each site row', async () => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Journal')
expect(menuLabels()).toEqual(
expect.arrayContaining(['Edit', 'Languages', 'Public URL', 'Delete…']),
)
})
it.each([
['Languages', '/settings/sites/journal/languages'],
['Public URL', '/settings/sites/journal/public-url'],
])('opens %s at %s', async (label, target) => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Journal')
await chooseMenuItem(label)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(target))
})
})
+2 -2
View File
@@ -131,7 +131,7 @@ describe('Sites: row menu', () => {
await openRowMenu('Journal') await openRowMenu('Journal')
const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf) const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
expect(labels).toEqual(['Edit', 'Set as default', 'Delete…']) expect(labels).toEqual(['Edit', 'Languages', 'Public URL', 'Set as default', 'Delete…'])
}) })
it('opens a bottom sheet titled with the site name below md', async () => { it('opens a bottom sheet titled with the site name below md', async () => {
@@ -142,7 +142,7 @@ describe('Sites: row menu', () => {
const sheet = dialog()! const sheet = dialog()!
expect(textOf(sheet.querySelector('h2'))).toBe('Journal') expect(textOf(sheet.querySelector('h2'))).toBe('Journal')
expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(3) expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(5)
}) })
}) })
@@ -0,0 +1,84 @@
export interface LanguageFixture {
lang: string
enabled?: boolean
versions: number
published: number
social?: string
derived?: boolean
}
export const AVAILABLE = [
...['en', 'en-GB', 'en-US', 'fr', 'fr-CA', 'pt', 'pt-BR', 'es', 'de', 'ar'].map((lang) => ({
lang,
substitute: null,
})),
{ lang: 'pt-PT', substitute: 'pt' },
]
export function languagesBody(revision: string, def: string, rows: LanguageFixture[]) {
return {
revision,
default: def,
needs_setup: false,
languages: rows.map((row) => ({
lang: row.lang,
enabled: row.enabled ?? true,
is_default: row.lang === def,
version_count: row.versions,
published_count: row.published,
social_locale: row.social ?? null,
social_locale_state: row.social ? 'set' : row.derived ? 'derived' : 'not_set',
})),
available: AVAILABLE,
}
}
export const JOURNAL_ROWS: LanguageFixture[] = [
{ lang: 'en', versions: 7, published: 6 },
{ lang: 'fr', versions: 4, published: 2, social: 'fr_FR' },
{ lang: 'de', versions: 4, published: 3 },
{ lang: 'ar', versions: 1, published: 0 },
{ lang: 'es', enabled: false, versions: 1, published: 1 },
{ lang: 'it', enabled: false, versions: 0, published: 0 },
]
export const JOURNAL = languagesBody('journal-1', 'en', JOURNAL_ROWS)
export const ATELIER = languagesBody('atelier-1', 'en', [
{ lang: 'en', versions: 3, published: 2 },
{ lang: 'en-GB', versions: 3, published: 1, derived: true },
{ lang: 'fr', versions: 2, published: 0, social: 'fr_FR' },
{ lang: 'pt-BR', versions: 1, published: 0, derived: true },
])
export const ARCHIVE = languagesBody('archive-1', 'pt-BR', [
{ lang: 'pt-BR', versions: 5, published: 4, derived: true },
])
export function siteRow(
id: number,
key: string,
name: string,
extra: Record<string, unknown> = {},
) {
return {
id,
name,
key,
base_url: `https://${key}.example.com`,
default_social_image_id: null,
default_robots: 'index',
locale: null,
is_default: id === 1,
article_count: 0,
public_url_pattern: null,
...extra,
}
}
export const SITES = [
siteRow(1, 'journal', 'Journal', { locale: 'en', article_count: 8 }),
siteRow(2, 'atelier', 'Atelier'),
siteRow(3, 'archive', 'Archive'),
siteRow(4, 'notes', 'Notes'),
]
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more