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": } Then the status is and 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": ""} Then the status is 422 and the body is "" 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 sends PATCH /v1/site/1 with a valid pattern Then the status is 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 Then the screen is "" () and shows 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