Files
RenewCMS/features/localization_public_api_and_surfaces.feature
T

558 lines
41 KiB
Gherkin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Feature: Localized public API, SEO output and the existing surfaces that show languages
A visitor or a frontend reads one published version by site, language and slug, or
resolves an article group (story) with an explicit fallback, and gets the other
published languages as alternates. The resolved SEO object, the sitemap and the
back-office surfaces that already show articles become language aware. Nothing
falls back silently, no draft is ever returned and no hidden sibling is named.
Background:
Given the design of record is docs/design/localization.md (Public lookup and URLs, Locale and SEO corrections), docs/design/localization-tables.md, docs/design/screens/localization-metadata-api.md (L10) and docs/design/screens/localization-surfaces.md (L12)
And the language rules, slug uniqueness per (site, lang) and unique (story_id, lang) of features/article_localization_storage.feature apply
And the site languages, default and social locale of features/site_languages.feature and the public_url_pattern of features/site_public_url_pattern.feature already exist
And error responses are plain text whose body is the snake_case code, or the existing apperr text where the route already used one
And a version is "published" when is_online is true, and "eligible" when it is published and its language is enabled on the site
And the public routes are open to everyone: a jwt cookie or API key is ignored, so a verified user never gets a draft from them
And handlers reach articles, sites and languages only through use cases and domain interfaces, validation goes through validate tags and validator.Validate.Struct, and no comment is added anywhere
And site "journal" (id 1, key "journal", base_url "https://www.journal.example", Locale "en-GB", default_description "Renovation notes.", public_url_pattern "https://journal.example.com/{lang}/{slug}") has these languages
| lang | default | enabled | social_locale |
| en | true | true | null |
| fr | false | true | fr_FR |
| de | false | true | null |
| ar | false | true | null |
| es | false | false | null |
And story 5 of journal holds these versions
| id | lang | slug | published | title |
| 10 | en | how-we-price-a-bathroom-renovation | yes | How we price a bathroom renovation |
| 12 | fr | comment-nous-chiffrons-une-renovation | yes | Comment nous chiffrons une rénovation |
| 11 | de | so-kalkulieren-wir-eine-badsanierung | yes | So kalkulieren wir eine Badsanierung |
| 13 | ar | takalif | no | Takalif |
| 14 | es | cuanto-cuesta | yes | Cuánto cuesta |
And story 6 of journal holds only the published en version 20 with slug "spring"
And story 7 of journal holds only the unpublished en version 21 with slug "winter"
And article 30 of journal is unassigned (lang "") and published with slug "legacy-note"
Rule: Exact lookup by site, language and slug never falls back
Scenario: A published version is returned with its alternates
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
Then the status is 200
And the body is the JSON of GET /v1/article/12 for an anonymous caller, with the same keys and values (id 12, lang "fr", story_id 5, slug, is_online true)
And the body has one more key "alternates", an array of the other eligible versions of story 5
And alternates is ordered default language first, then by tag: en (10), de (11)
And each alternate has exactly the keys id, lang, story_id, slug and url
And the url of the en alternate is "https://journal.example.com/en/how-we-price-a-bathroom-renovation"
Scenario: Alternates leave out the served version, drafts and disabled languages
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/how-we-price-a-bathroom-renovation
Then alternates lists fr (12) and de (11)
And it does not list ar (13, unpublished) nor es (14, language disabled)
And no key or message anywhere in the response mentions version 13
Scenario: A disabled language stays readable by exact lookup
When an anonymous client sends GET /v1/public/site/journal/lang/es/article/cuanto-cuesta
Then the status is 200 and lang is "es"
And alternates lists en, fr and de
Scenario: A story with one version has an empty alternates array
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring
Then the status is 200 and alternates is []
Scenario Outline: The lookup matches site, language and slug only
When an anonymous client sends GET /v1/public/site/<key>/lang/<lang>/article/<slug>
Then the status is <status> and the body is "<body>"
Examples:
| key | lang | slug | status | body |
| journal | de | comment-nous-chiffrons-une-renovation | 404 | article_not_found |
| journal | fr | how-we-price-a-bathroom-renovation | 404 | article_not_found |
| journal | ar | takalif | 404 | article_not_found |
| journal | en | winter | 404 | article_not_found |
| journal | en | nope | 404 | article_not_found |
| journal | FR | comment-nous-chiffrons-une-renovation | 200 | |
| journal | pt-br | spring | 404 | article_not_found |
| journal | xx | spring | 404 | article_not_found |
| journal | e | spring | 400 | invalid_language |
| journal | pt_BR | spring | 400 | invalid_language |
| nowhere | fr | comment-nous-chiffrons-une-renovation | 404 | site_not_found |
Scenario: The same slug in another site or language is a different page
Given site "atelier" holds a published fr version with slug "comment-nous-chiffrons-une-renovation"
When an anonymous client sends GET /v1/public/site/atelier/lang/fr/article/comment-nous-chiffrons-une-renovation
Then the status is 200 and site_id is atelier's
And the same request on journal still answers version 12
Scenario: An unassigned article has no language route
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/legacy-note
Then the status is 404 and the body is "article_not_found"
And GET /v1/article/30 still answers 200 as before
Scenario: A draft never answers, even to its owner
Given the owner has a valid jwt cookie
When the client sends GET /v1/public/site/journal/lang/en/article/winter with that cookie
Then the status is 404 and the body is "article_not_found"
Scenario: Unpublishing removes the version at once
Given version 12 is unpublished
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
Then the status is 404 and the body is "article_not_found"
And version 12 is no longer in the alternates of version 10
Scenario: A changed slug breaks the old address and creates no redirect
Given the slug of version 12 is changed to "nouveau-titre"
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
Then the status is 404 and the body is "article_not_found"
And GET /v1/public/site/journal/lang/fr/article/nouveau-titre answers 200
Scenario: The body format parameter behaves as on GET /v1/article/{id}
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring?format=json
Then body_format is "json"
And ?format=pdf answers 400 with the existing body format message
Scenario: A repository failure answers 500
Given the article repository fails on its next call
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring
Then the status is 500 and the body is "Internal server error"
Rule: Story resolution falls back only when asked and only to the default language
Scenario: The requested language is published
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr
Then the status is 200 and the article is version 12
And lang is "fr", requested_lang is "fr" and fallback is false
And alternates lists en and de
Scenario: The requested language is missing and no fallback is asked
Given story 5 has no published fr version
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr
Then the status is 404 and the body is "language_not_available"
Scenario: The requested language is missing and the default is the fallback
Given story 5 has no published fr version
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
Then the status is 200 and the article is version 10
And lang is "en", requested_lang is "fr" and fallback is true
Scenario: The requested language is unpublished, not only missing
Given version 12 is unpublished
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
Then the article is version 10 and fallback is true
Scenario: Neither the requested language nor the default is published
Given versions 12 and 10 are unpublished
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
Then the status is 404 and the body is "language_not_available"
And version 11 (de, published) is not returned: no third language is tried
Scenario: The default language itself is requested and unpublished
Given version 10 is unpublished
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=en&fallback=default
Then the status is 404 and the body is "language_not_available"
Scenario: No language means the site default language
When an anonymous client sends GET /v1/public/site/journal/story/5
Then the article is version 10, lang is "en", requested_lang is "en" and fallback is false
Scenario: Changing the default changes only requests that omit the language
Given the owner makes fr the default language of journal
When an anonymous client sends GET /v1/public/site/journal/story/5
Then the article is version 12 and requested_lang is "fr"
And GET /v1/public/site/journal/story/5?lang=en still answers version 10
And GET /v1/public/site/journal/lang/en/article/how-we-price-a-bathroom-renovation still answers version 10
And GET /v1/article/10 still answers version 10
Scenario: A story of a site with no confirmed default cannot be resolved without a language
Given site "notes" has no confirmed default language
When an anonymous client sends GET /v1/public/site/notes/story/<story>
Then the status is 404 and the body is "language_not_available"
And the same request with an explicit lang that matches a published version answers 200
Scenario: A disabled language is served when asked explicitly
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=es
Then the status is 200 and lang is "es"
Scenario Outline: Refused and unknown requests
When an anonymous client sends GET /v1/public/site/<key>/story/<story>?<query>
Then the status is <status> and the body is "<body>"
Examples:
| key | story | query | status | body |
| journal | 6 | lang=fr | 404 | language_not_available |
| journal | 7 | lang=en | 404 | language_not_available |
| journal | 999 | lang=en | 404 | language_not_available |
| journal | 5 | lang=fr&fallback=latest | 400 | apperr.BodyErrorMessage |
| journal | 5 | lang=fr&fallback= | 400 | apperr.BodyErrorMessage |
| journal | 5 | lang=english | 400 | invalid_language |
| journal | abc | lang=en | 400 | apperr.IdUint32ErrorMessage |
| nowhere | 5 | lang=en | 404 | site_not_found |
Scenario: A story of another site is not found
When an anonymous client sends GET /v1/public/site/atelier/story/5?lang=en
Then the status is 404 and the body is "language_not_available"
Scenario: The response carries no more than a direct read would
When an anonymous client resolves story 5 with lang=fr
Then the keys are those of the exact lookup plus requested_lang and fallback
And created_by, capabilities and every draft or sibling detail are absent as on an anonymous GET /v1/article/{id}
Scenario: Existing article routes are unchanged
Then GET /v1/article/{id}, GET /v1/article/ and PATCH /v1/article/{id}/publish keep their statuses, bodies and access rules
Rule: The resolved SEO object is language aware
Scenario: The shape grows by additive keys
When an anonymous caller sends GET /v1/article/12/seo
Then the body has the keys of features/seo_output_backend.feature plus "lang" and "alternates"
And og has the keys of that feature plus "locale_alternates"
And fallbacks has the keys of that feature plus "site_text_skipped"
And lang is "fr" and alternates has the shape and the order of the public alternates
Scenario: An unassigned article keeps the legacy values
When an anonymous caller sends GET /v1/article/30/seo
Then lang is "" and alternates is [] and og.locale_alternates is []
And canonical_url is "https://www.journal.example/legacy-note" because {lang} cannot be filled, whatever the pattern
And og.locale is the raw Site.Locale "en-GB" as before this slice
Scenario Outline: The canonical is the localized page unless overridden
Given journal's public_url_pattern is <pattern>
And the version <id> has canonical_url "<override>"
When an anonymous caller sends GET /v1/article/<id>/seo
Then canonical_url and og.url are "<canonical>" and fallbacks.canonical is "<source>"
And json_ld.mainEntityOfPage.@id equals canonical_url
Examples:
| pattern | id | override | canonical | source |
| https://journal.example.com/{lang}/{slug} | 12 | | https://journal.example.com/fr/comment-nous-chiffrons-une-renovation | pattern |
| https://journal.example.com/{lang}/articles/{slug} | 11 | | https://journal.example.com/de/articles/so-kalkulieren-wir-eine-badsanierung | pattern |
| https://journal.example.com/{lang}/{slug} | 12 | https://other.example/a | https://other.example/a | canonical_url |
| null | 12 | | https://www.journal.example/comment-nous-chiffrons-une-renovation | site |
| https://journal.example.com/{slug} | 12 | | https://journal.example.com/comment-nous-chiffrons-une-renovation | pattern |
Scenario: A region tag is kept in the address
Given a version in pt-BR with slug "reforma" on a site whose pattern holds {lang}
Then its canonical contains "/pt-BR/reforma"
Scenario: Saving the pattern now changes canonical output
Given journal has no pattern and the canonical of version 12 is "https://www.journal.example/comment-nous-chiffrons-une-renovation"
When an owner sets the pattern "https://journal.example.com/{lang}/{slug}"
Then the canonical of version 12 becomes "https://journal.example.com/fr/comment-nous-chiffrons-une-renovation"
And a canonical override on any version is unchanged
Scenario Outline: og:locale is derived in a fixed order and never invented
Given the site has Locale "<site_locale>", the language row "<lang>" has social_locale <explicit>
When an anonymous caller sends GET /v1/article/<id>/seo for a published version in <lang>
Then og.locale is "<og_locale>"
Examples:
| site_locale | lang | explicit | id | og_locale | rule |
| en-GB | fr | "fr_FR" | 12 | fr_FR | 1 explicit social locale |
| en-GB | fr | "fr_CA" | 12 | fr_CA | explicit wins over every other rule |
| en-GB | pt-BR | null | 40 | pt_BR | 2 the tag already has a region |
| en-GB | en | null | 10 | en_GB | 3 Site.Locale has the same language part |
| fr-CA | fr | null | 12 | fr_CA | 3 |
| en-GB | de | null | 11 | | 4 nothing resolves, the value is omitted |
| en | en | null | 10 | | 4 a bare Site.Locale has no region to borrow |
| null | de | null | 11 | | 4 |
| en-GB | fr-CA | "fr_FR" | 41 | fr_FR | explicit wins over the tag's region |
Scenario: An unresolved og:locale is an empty string and its alternates are left out
Given the version 11 (de) resolves no social locale
When an anonymous caller sends GET /v1/article/11/seo
Then og.locale is "" and og.locale_alternates is []
Scenario: og:locale:alternate lists the other eligible versions that resolve
When an anonymous caller sends GET /v1/article/10/seo
Then og.locale is "en_GB"
And og.locale_alternates is ["fr_FR"]
And de is left out because it resolves no social locale, ar and es because they are not eligible
Scenario Outline: SEO fields fall back in the language of the version
Given version 12 (fr) has seo_title "<seo_title>", seo_description "<seo_description>", excerpt "<excerpt>" and a body whose first sentence is "<sentence>"
And journal's default_description is "Renovation notes." written in the default language "en"
When an anonymous caller sends GET /v1/article/12/seo
Then title is "<title>" and fallbacks.title is "<title_source>"
And description is "<description>" and fallbacks.description is "<description_source>"
And fallbacks.site_text_skipped is <skipped>
Examples:
| seo_title | seo_description | excerpt | sentence | title | title_source | description | description_source | skipped |
| Mon titre | Ma description. | Un résumé. | Première. | Mon titre \| Journal | seo_title | Ma description. | seo_description | false |
| | | Un résumé. | Première. | Comment nous chiffrons une rénovation \| Journal | title | Un résumé. | excerpt | false |
| | | | Première. | Comment nous chiffrons une rénovation \| Journal | title | Première. | body | false |
| | | | | Comment nous chiffrons une rénovation \| Journal | title | | none | true |
Scenario Outline: Site default text applies only to articles in its own language
Given journal's default_description is "Renovation notes." and the version has no description, excerpt or body sentence
When an anonymous caller sends GET /v1/article/<id>/seo for the version in <lang>
Then description is "<description>" and fallbacks.description is "<source>"
And fallbacks.site_text_skipped is <skipped>
Examples:
| id | lang | description | source | skipped |
| 10 | en | Renovation notes. | site | false |
| 12 | fr | | none | true |
| 30 | (none) | Renovation notes. | site | false |
Scenario: A site without a default description withholds nothing
Given journal has no default_description
When an anonymous caller sends GET /v1/article/12/seo
Then fallbacks.description is "none" and fallbacks.site_text_skipped is false
Scenario: Changing the default language changes which articles may use the site text
Given the owner makes fr the default language of journal
When an anonymous caller sends GET /v1/article/10/seo for the en version without description
Then fallbacks.site_text_skipped is true
And this is the documented limitation of treating the site text as written in the current default language
Scenario: The SEO check and the list status follow the same language rule
Given journal's default_description is 108 characters long and versions 10 (en, default) and 12 (fr) have no description, excerpt or body sentence
When the owner sends POST /v1/article/10/seo/check and POST /v1/article/12/seo/check with {}
Then only the fr check reports desc_short
And the seo_suggestions of 12 in GET /v1/article/ is one more than that of 10
When the owner makes fr the default language of journal
Then the next GET /v1/article/ reports one more suggestion for 10 than for 12, without any edit of the articles
Scenario: Brand text and images stay shared
Then title_template and site_name apply to every language
And the image chain (social, cover, site default image) is unchanged and each version's cover_image_alt stays its own
Scenario: The SEO read stays idempotent and public
Then the visibility rules, 404 for a draft to a non-verified caller and 400 for a malformed id of features/seo_output_backend.feature are unchanged
And no repository write and no activity event happen
Rule: The sitemap lists every eligible version at its localized address
Scenario: Each eligible version is listed once
When an anonymous caller sends GET /v1/site/journal/sitemap
Then the items are the published versions of journal in enabled languages
And each item has the keys url, modified_at and noindex plus "lang"
And the url of version 12 is "https://journal.example.com/fr/comment-nous-chiffrons-une-renovation"
And the versions 14 (es, disabled), 13 and 21 (drafts) are absent
And X-Total-Count counts the listed items
Scenario: Without a pattern the legacy address is kept
Given journal has no pattern
When an anonymous caller sends GET /v1/site/journal/sitemap
Then the url of version 20 is "https://www.journal.example/spring" as before this slice
Rule: The public routes and the sitemap are rate limited per client
Scenario: The public article routes and the sitemap share one budget per client
Given one client address has sent 120 requests within a minute to /v1/public/... and /v1/site/journal/sitemap together
When it sends one more request to either
Then the response status is 429 with Retry-After and the body "Too many requests. Please try again later."
And another client address is still answered
And X-Real-IP is used as the client address only when the connection comes from TRUSTED_PROXIES
Rule: Media usage names the language and counts a story once
Background:
Given image 7 "bathroom.jpg" is the cover of versions 10 (en), 12 (fr) and 11 (de) of story 5, and is in the body of version 12
And image 7 is also the body image of article 20 (story 6, en) and of unassigned article 30
And image 7 is the default_social_image of site journal and the avatar of author 3
Scenario: Article usage items carry lang and story_id
When the client sends GET /v1/media/7/usage with a valid cookie
Then every item of type "article" has the existing keys type, id, title, published and fields plus "story_id" and "lang"
And there is one article item per version, ordered by id: 10, 11, 12, 20, 30
And item 12 is {"type": "article", "id": 12, "title": "Comment nous chiffrons une rénovation", "published": true, "fields": ["body", "cover"], "story_id": 5, "lang": "fr"}
And item 20 has story_id 6 and lang "en"
Scenario: An unassigned article has no language and is its own group
When the client sends GET /v1/media/7/usage
Then item 30 has "lang" "" and its own story_id as stored, and is never grouped with another article
Scenario: Items of other types are unchanged
When the client sends GET /v1/media/7/usage
Then the author, site and entry items carry neither "lang" nor "story_id"
And the order stays articles, authors, sites, then entries as in features/media_field_library.feature
Scenario: An image used by 3 versions of one story is one use
When the client sends GET /v1/media/7/usage
Then the three items 10, 11 and 12 share story_id 5, so a consumer counts them as one article
And GET /v1/media/ reports used_in 5 for image 7: story 5, story 6, article 30, the author and the site
Scenario: A single-language site is unchanged for existing consumers
Given site "notes" has one language
When the client sends GET /v1/media/8/usage for an image used by one article of notes
Then the item has the existing keys plus story_id and lang, and the count is the same as before this slice
Scenario: The usage of an unused image is still an empty array
When the client sends GET /v1/media/4/usage
Then the status is 200 and the body is []
Scenario: The delete-in-use dialog counts stories
Given image 7 is open in the Delete dialog of the Media library
Then the article count in the copy and the lists is 2 articles (story 5, story 6) plus the unassigned article 30, not 5 versions
Scenario: The Media usage panel groups versions under their article (loc.l12.media)
When the user opens the usage of image 7 in Media on journal
Then the Articles section lists story 5 once, with the title of its default-language version and the language of each version that uses the image (EN, DE, FR)
And the Articles heading counts articles, not versions
And a one-language site lists articles exactly as before
Rule: Language configuration shows the derived social locale
Scenario: A bare tag borrows the region of Site.Locale
Given site "atelier" has Locale "en-GB" and a language "en" with social_locale null
When an owner reads GET /v1/site/atelier/languages
Then language "en" has social_locale_state "derived" and an effective value "en_GB"
And this replaces the deferral in features/site_languages.feature ("Only the explicit value and the tag's own region resolve the social locale")
Scenario: No region anywhere stays not set
Given site "atelier" has Locale "en" and a language "en" with social_locale null
Then language "en" has social_locale_state "not_set" and an effective value null
Rule: Integration page requests
Scenario: The catalogue documents the new requests
Then web/src/data/apiCatalogue.ts lists GET /v1/public/site/{key}/lang/{lang}/article/{slug} and GET /v1/public/site/{key}/story/{story_id}
And it documents alternates, requested_lang, fallback, the errors article_not_found, language_not_available, site_not_found and invalid_language, and the new keys of GET /v1/article/{id}/seo
And the catalogue sentence "Titles can't be changed after creation." is replaced by one that matches the editable title
And the catalogue counts in web/src/views/IntegrationView.spec.ts and the drift test test/api_catalogue_drift_test.go are updated
Rule: MCP server and llms output
Scenario: The MCP tools expose the language
Then the tool list_articles accepts an optional "lang" input that is sent as the lang query parameter
And the tool descriptions no longer say that a title cannot be changed
And get_article, list_articles and get_sitemap return the JSON they already return, which now carries lang and story_id
And no tool is added for the public routes
And the MCP server keeps calling the authenticated HTTP routes with its API key rather than the use cases, for the reasons in docs/mcp.md
Scenario: The llms documents mention languages
Then docs/llms.md, docs/llm/ and the llms-full output describe lang, story_id, alternates, the two public routes and the fallback rule
And the generated output stays in sync (task build:llms leaves no diff)
Rule: SPA, Search and sharing panel (L10)
Scenario Outline: The panel of a localized version
Given the editor is open on <version> of a site with the languages above
And <setup>
When the Search and sharing panel is open
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| version | setup | state | id | visible |
| the French version 12, published | the pattern https://journal.example.com/{lang}/{slug} is set, fr_FR is the social locale, the description is empty with no excerpt or body sentence | Search and sharing: French | loc.seo.noFallback | heading "Search and sharing · French", subtitle "Applies only to the French version. Page language: fr.", the "Canonical" badge with https://journal.example.com/fr/comment-nous-chiffrons-une-renovation and the note "Built from the Public URL pattern https://journal.example.com/{lang}/{slug}. Each language points to itself unless you set a canonical override.", the card "Alternate languages" with "Published versions in enabled languages only.", the rows EN and DE with their URLs and the "Published" badge and the note "Drafts and the disabled Spanish language are left out.", the "Social locale" card with the "og:locale" badge and "Set: fr_FR" and the note "Set by an owner for French in Journal languages. A region is never guessed from the language.", the "Address (slug)" field read-only with the help "Unique within Journal and French. Editing the title never changes the address.", the "Edit slug" button with the notes "Editing the title never changes the slug. Only Edit slug does." and "Links to the old address will stop working. RenewCMS does not create redirects.", the field "Page title" with "Comment nous chiffrons une rénovation \| Journal" and the help "Fallback: French title", the field "Description" empty with the help "No fallback. Empty. English site default not used. The tag is left out." and the field "Image alternative text" with the help "Written per language." |
| the French version 12, published | the owner opens Edit slug | Edit slug | loc.slug.published | the dialog "Edit slug for the French version" with "The address changes for French only.", the field "Address (slug)" focused and in monospace, the warning "This version is published" / "Links to the old address will stop working. RenewCMS does not create redirects.", the note "Editing the title never changes the slug.", "Cancel" and the primary "Change address" |
| the French version 12 | journal has no pattern | Public URL not configured | loc.url.unset | the info alert "Public URL not configured" / "Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work." with the secondary small button "Open Public URL settings" (to /settings/sites/journal/public-url), and no canonical card, no alternates card and no address |
| the French version of a site where fr has no social locale and the tag has no region | the pattern is set | Social locale not set | loc.l10.noSocial | the "Social locale" card with the "og:locale" badge and "Social locale not set" and the note "French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages." |
Scenario: A draft version has no page address
Given the version is a draft
Then the canonical card shows the address the page will have and the alternates card lists the other published versions only
Scenario: A canonical override is shown as the override
Given the version has canonical_url "https://other.example/a"
Then the canonical card shows "https://other.example/a" and the note says it was set by hand
Scenario: Fallback labels follow the API
Then a field whose fallbacks source is "title" shows "Fallback: <Language> title"
And a field whose source is "excerpt" shows "Fallback: <Language> excerpt"
And a field whose source is "body" shows "Fallback: <Language> body"
And a field with source "none" and site_text_skipped false shows "No fallback"
And a field with source "none" and site_text_skipped true shows "No fallback. Empty. <Default language> site default not used. The tag is left out."
And a field whose source is "site" shows "Fallback: <Language> site description"
Scenario Outline: Site text fallback by article language
Given the open version is <article> and the site description is "Rénovation et conseils pour la maison" written in the site default language
Then the Description field shows <help> and the badge "<badge>"
Examples:
| article | help | badge |
| a version in the default language | "Fallback: <Language> site description" and the used text | Uses site text |
| a version in another language | "No fallback. Empty. <Default language> site default not used." | Tag left out |
Scenario: A single-language site shows no language text
Given the site enables one language
Then the panel keeps its current layout: no "Alternate languages" card, no "Social locale" card and no language in the heading
Scenario: Editing the slug of a draft needs no warning
Given the version is a draft
When the owner opens Edit slug
Then the dialog has no "This version is published" alert
Scenario Outline: Slug save outcomes
When the owner changes the address and presses "Change address"
Then the PATCH /v1/article/12 sends {"slug": "...", "if_revision": <revision>} and the result is <result>
Examples:
| response | result |
| 200 | the dialog closes, the address and canonical update, a success toast |
| 400 invalid_slug | the field shows the existing invalid slug message and the dialog stays |
| 409 slug_taken | the field shows the existing slug-taken message and the dialog stays |
| 409 revision_conflict | the conflict dialog of features/localization_translation_flows.feature opens |
Rule: SPA, Integration page (L10 api)
Scenario: The Integration page documents the language requests
Given the route /integration
Then it shows the cards "1. Exact lookup: site + language + slug", "2. Resolve by article group, with fallback", "3. Errors" and "Existing, unchanged" with the texts of loc.l10.api
And the sample responses use the real shapes of this feature
Rule: SPA, existing surfaces (L12)
Scenario Outline: Existing surfaces on a multilingual site
Given the site journal as above and <setup>
When <trigger>
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| setup | trigger | state | id | visible |
| the reader can read every language | the user opens Home | Home: recent articles | loc.l12.home | recent articles listed once per article (GET /v1/article/?group=story), the title of the default-language version, and a language code chip only when the shown version is not in the default language |
| the reader can read only the French versions | the user opens Home | Home: reader of French only | loc.l12.homeFr | the titles in French with the code "FR" |
| events carry a lang param | the user opens Activity | Activity | loc.l12.activity | sentences that name the language, for example "Published French version of …" |
| an image used by 3 versions of one article | the user opens the usage of the image in Media | Media usage | loc.l12.media | the image counted once for the article (grouped by the story_id of GET /v1/media/{id}/usage), the versions that use it listed with their language (from the lang of each item) |
| the open version is French | the user opens the article menu | Article menu labels | loc.l12.menu | "Publish French" / "Unpublish French" and "Delete French version" on a multilingual site |
| the open version is French (id 14) | the user opens the editor Details panel | API URL copy | loc.l12.apiCopy | the card "Public API URL" with "Each language version has its own address. The URL carries the language." and the row "/v1/article/14?lang=fr" with a copy icon; copying shows the toast "API URL copied" / "/v1/article/14?lang=fr" |
| the open version is French with a pattern set | the user opens the Search preview | SEO preview | loc.l12.seoPreview | the page "Search preview · French version", the card "How it can appear in search" with the breadcrumb address journal.example.com › fr › comment-nous-chiffrons-une-renovation, the title "Comment nous chiffrons une rénovation \| Journal", the description from the excerpt and the note "Shows the localized address and canonical. Description: Fallback: French excerpt." |
Scenario: Single-language sites show none of it
Given the site enables one language
Then Home, Activity, Media usage, the article menu, the API URL and the SEO preview look and read as before this slice
And Home counts articles, not versions
Scenario: Home counts articles
Given 8 stories with 17 versions
Then Home's total and counts are 8 and the recent list never repeats a story
Scenario: The API URL carries the language only when it is known
Given the open version has lang "" (unassigned)
Then the copied URL is "/v1/article/<id>" with no lang query
Scenario: Copying the URL does not change the request contract
Then GET /v1/article/14?lang=fr answers the same article as GET /v1/article/14 and an unknown lang value is ignored by that route
Rule: Real-database lookups
Scenario: The lookup indexes serve the public queries
Then exact lookup by (site_id, lang, slug) and story resolution by (story_id, lang) return the same rows on SQLite, MySQL and PostgreSQL
And a soft-deleted version, an offline version and an unassigned article are never returned
And the unique indexes idx_articles_site_slug and idx_articles_story_lang exist after Migrate and Migrate again changes nothing
Rule: Layering and documentation
Scenario: Layering is respected
Then the lookup, the resolution, the alternates, the og:locale derivation and the fallback order live in use cases and domain packages
And the domain imports no GORM or HTTP, the use cases import no persistence, mailer, models, gorm, net/http or chi
And the two public routes are mounted by api/router.go with no auth middleware and built only in api/dependecyInjection.go
Scenario: Older features are amended in the same commit
Then these older features and their .qa.md are edited, and nothing else:
| file | edit |
| features/media_library.feature (Rule: Usage) | the example body of "Usage lists the articles whose body contains the url" gains "story_id" and "lang" on each item (assigned articles: the stored story_id and lang) |
| features/media_library.feature (Rule: Usage) | a sentence that article items are one per version and carry story_id and lang |
| features/media_field_library.feature | line 11: "used_in" counts articles by story (a story once), authors, sites and entries; line 12: article items carry story_id and lang; "The articles heading counts articles only" states articles are grouped by story_id, not by item |
| features/media_field_library.feature (delete-in-use scenarios, lines ~234-258) | the article count is the number of distinct stories (plus unassigned articles) |
| features/media_browse.feature | the usage mock at line ~214 gains story_id and lang; counts stay by article |
| features/seo_data_backend.feature and features/sites_backend.feature | only where they assert the exact key set of an article usage item or used_in of an image shared by versions of one story |
| features/seo_output_backend.feature | the exact SEO key set gains lang, alternates, og.locale_alternates, fallbacks.site_text_skipped; the canonical rule and og.locale derivation of assigned articles are replaced; unassigned and pattern-less cases stay |
| features/site_languages.feature | "Only the explicit value and the tag's own region resolve the social locale" is replaced by the derived state |
| features/integration.feature | the request catalogue lists the two public routes and the new SEO keys |
| features/article_localization_storage.feature | none unless the unique indexes are renamed |
Scenario: The documentation describes the contract
Then docs/api/article-localization.md documents both public routes, alternates, requested_lang, fallback, the errors, the SEO keys, the og:locale order, the fallback order and the canonical rule
And docs/api.md, docs/api/articles.md and docs/release-notes.md announce the change
And the SPA mocks of this slice are written from those pages