Compare commits

...
90 changed files with 3718 additions and 641 deletions

No files matched your search

+6 -4
View File
@@ -10,6 +10,7 @@ import (
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/update"
@@ -152,13 +153,14 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
func getUseCases(repos *Repositories, settings config.Settings) *UseCases { func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
return &UseCases{ return &UseCases{
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
+100 -22
View File
@@ -12,6 +12,7 @@ import (
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -41,6 +42,53 @@ func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleU
} }
} }
type articleResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat domain.BodyFormat `json:"body_format"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body)
if err != nil {
return articleResponse{}, err
}
body = encoded
}
return articleResponse{
ID: article.ID,
Title: article.Title,
Body: body,
BodyFormat: format,
IsOnline: article.IsOnline,
CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt,
}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
response, err := toArticleResponse(article, format)
if err != nil {
return nil, err
}
return json.Marshal(response)
}
func parseBodyFormat(w http.ResponseWriter, r *http.Request) (domain.BodyFormat, bool) {
format, err := domain.ParseBodyFormat(r.URL.Query().Get("format"))
if err != nil {
http.Error(w, apperr.ArticleBodyFormatErrorMessage, http.StatusBadRequest)
return "", false
}
return format, true
}
func parseArticleId(r *http.Request) (uint32, error) { func parseArticleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err return uint32(id), err
@@ -53,11 +101,16 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var article domain.Article var article domain.Article
if h.callerIsVerified(r) { if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id) article, err = h.GetUseCase.GetArticle(id, format)
} else { } else {
article, err = h.GetUseCase.GetOnlineArticle(id) article, err = h.GetUseCase.GetOnlineArticle(id, format)
} }
if err != nil { if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) { if errors.Is(err, domain.ErrArticleNotFound) {
@@ -68,7 +121,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
articleJson, err := json.Marshal(article) articleJson, err := encodeArticle(article, format)
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -80,6 +133,11 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle PostArticle var localArticle PostArticle
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -94,6 +152,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{ createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{
Title: localArticle.Title, Title: localArticle.Title,
Body: localArticle.Body, Body: localArticle.Body,
Format: format,
}) })
if err != nil { if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) { if errors.Is(err, domain.ErrInvalidTitle) {
@@ -104,16 +163,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
articleJson, err := json.Marshal(createdArticle) writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
}
} }
func writeArticleErrorResponse(w http.ResponseWriter, err error) { func writeArticleErrorResponse(w http.ResponseWriter, err error) {
@@ -124,13 +174,16 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
func writeArticleJsonResponse(w http.ResponseWriter, article domain.Article) { func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := json.Marshal(article) articleJson, err := encodeArticle(article, format)
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
} }
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(articleJson); err != nil { if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err) log.Printf("Failed to write response: %v", err)
} }
@@ -143,13 +196,18 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline) format, ok := parseBodyFormat(w, r)
if !ok {
return
}
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline, format)
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
} }
writeArticleJsonResponse(w, updatedArticle) writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
} }
type UpdateArticleBody struct { type UpdateArticleBody struct {
@@ -163,6 +221,11 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle UpdateArticleBody var localArticle UpdateArticleBody
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -174,13 +237,13 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body) updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body, format)
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
} }
writeArticleJsonResponse(w, updatedArticle) writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
} }
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -206,19 +269,34 @@ func (h *Handler) callerIsVerified(r *http.Request) bool {
} }
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var articles []domain.Article var articles []domain.Article
var err error var err error
if h.callerIsVerified(r) { if h.callerIsVerified(r) {
articles, err = h.ListUseCase.ListAllArticles() articles, err = h.ListUseCase.ListAllArticles(format)
} else { } else {
articles, err = h.ListUseCase.ListOnlineArticles() articles, err = h.ListUseCase.ListOnlineArticles(format)
} }
if err != nil { if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError) http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
return return
} }
articlesJson, err := json.Marshal(articles) responses := make([]articleResponse, 0, len(articles))
for _, article := range articles {
response, err := toArticleResponse(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
responses = append(responses, response)
}
articlesJson, err := json.Marshal(responses)
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
+1
View File
@@ -34,3 +34,4 @@ const UpdateAlreadyRunningErrorMessage = "An update is already running or waitin
const NothingToUpdateErrorMessage = "There is nothing to update." const NothingToUpdateErrorMessage = "There is nothing to update."
const UpdateRefusedErrorMessage = "This update cannot be downloaded." const UpdateRefusedErrorMessage = "This update cannot be downloaded."
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again." const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
+97
View File
@@ -0,0 +1,97 @@
# Reading articles
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
## Routes that honour `format`
- `GET /v1/article/`
- `GET /v1/article/{id}`
- `POST /v1/article/`
- `PATCH /v1/article/{id}`
- `PATCH /v1/article/{id}/publish`
- `PATCH /v1/article/{id}/unpublish`
Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`.
## The `format` parameter
| Value | `body` | `body_format` |
| --- | --- | --- |
| empty or absent | markdown string | `markdown` |
| `markdown` | markdown string | `markdown` |
| `html` | the stored HTML string | `html` |
| `json` | JSON array of blocks | `json` |
Values are case-insensitive. When `format` is repeated, the first value is used. Any other value answers `400` with the plain-text message `The format must be one of: markdown, html, json.` before any article is read or written. An invalid id is reported first, ahead of the format.
Breaking change: the default used to be HTML. Clients that need HTML must send `?format=html`. The web interface does so.
## Article object
```json
{
"id": 1,
"title": "Backyard Renovation Update",
"body": "## Plan\n\nReplace the **old** deck.",
"body_format": "markdown",
"is_online": true,
"created_at": "2026-03-12T09:14:00Z",
"updated_at": "2026-09-10T07:00:00Z"
}
```
## Markdown output
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
### Text alignment
Markdown has no syntax for alignment, so centered, right-aligned and justified paragraphs and headings are wrapped in a `div` with an inline style. The blank lines around the content make CommonMark parse the inside as markdown:
```markdown
<div style="text-align: center">
A **centered** paragraph
</div>
```
Left alignment is the default and produces no wrapper. Your markdown renderer must allow raw HTML for the alignment to show (`html: true` in markdown-it); otherwise the wrapper tags appear as text, and a client that does not want them can request `?format=json` instead, where the alignment is a field.
## JSON block schema
`body` is a native JSON array. Every block has a `type`.
| Block | Fields |
| --- | --- |
| `heading` | `level` (1 to 6), `align` (only when `center`, `right` or `justify`), `children` (text nodes) |
| `paragraph` | `align` (only when `center`, `right` or `justify`), `children` (text nodes, empty array for an empty paragraph) |
| `list` | `ordered` (boolean), `items` (array of items, each an array of blocks) |
| `blockquote` | `children` (blocks) |
| `code` | `language` (empty string when unknown), `text` |
| `hr` | none |
| `image` | `src`, `alt` (empty string when missing) |
Text node: `{"type":"text","text":"...","marks":[...]}`. `marks` is always an array, listed outermost first. A mark is `{"type":"bold"}`, `{"type":"italic"}` or `{"type":"link","href":"..."}`. A line break becomes a `"\n"` inside the text. Adjacent text with identical marks is merged. Inline code, underline, strike-through and other formatting degrade to plain text. An image inside a paragraph becomes its own block and splits the paragraph. An empty body is `[]`.
Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
```json
[
{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},
{"type":"paragraph","children":[
{"type":"text","text":"Replace the ","marks":[]},
{"type":"text","text":"old","marks":[{"type":"bold"}]},
{"type":"text","text":" deck.","marks":[]}
]}
]
```
## Errors
| Status | Body |
| --- | --- |
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
| `404` | `Article not found` |
| `500` | `Internal server error` when the body cannot be converted |
+17 -7
View File
@@ -47,7 +47,7 @@ These are the RenewCMS components, built from the primitives on the first page .
### AuthShell ### AuthShell
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links and the theme select sit outside the card, so the card itself stays about one task. One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links, the language chip and the theme select sit outside the card, so the card itself stays about one task.
Log in Welcome back. Log in to manage your articles. Log in Welcome back. Log in to manage your articles.
@@ -55,7 +55,7 @@ default slot
Forgot password? System Forgot password? System
Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer. Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose. Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer, next to the language chip (see LanguageSwitcher). Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits. Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits.
@@ -383,12 +383,13 @@ The Product components board was not regenerated for these. They are written fro
### UserMenu (changed) ### UserMenu (changed)
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice and Log out. The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice, the language choice and Log out.
- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text. - **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text.
- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username. - **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username.
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, Log out (log-out). The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`. - **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, a "Theme" section label, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, a "Language" section label, English and Français (same row as the theme items, a check icon on the current one), a separator, Log out (log-out). Section labels are 11 px, weight 500, uppercase, letter-spacing .04em, `--ui-text-muted`, padded 6px 8px 2px. The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`.
- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme items keep the same order and separators. - **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme and language items keep the same order, section labels (padded 8px 16px 2px) and separators.
- **Language items:** a `menuitemradio` group named by the "Language" label. Each item is labelled with the language's own name (English, Français) and carries `lang="en"` / `lang="fr"`. Choosing one applies the language at once and keeps the menu open, like the theme items. See LanguageSwitcher.
- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items. - **Keyboard:** Escape or a click outside closes it. Arrow keys move between items.
- The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`. - The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`.
@@ -734,7 +735,16 @@ Three choices: System (the default), Light and Dark. The toggle at the top of th
Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint. Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint.
ARIA Menu items use menuitemcheckbox , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme". ARIA Menu items use menuitemradio , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
### LanguageSwitcher
Two choices: English and Français. Each is always written in its own language, whatever the interface language. It appears in two places and never as a form select.
- **Account menu (signed in):** a "Language" section between the theme items and Log out. See UserMenu (changed).
- **Auth footer (signed out):** a chip beside the System theme chip, in the footer outside the card. The chip is 28 px high on desktop and 44 px on mobile, 8 px side padding, 1 px `--ui-border-accented` border, `--ui-text-toned` text, a 14 px lucide `languages` icon, then the current language's own name. `aria-haspopup="menu"`, `aria-expanded`, `aria-label` "Language: English". While open it takes `--ui-bg-elevated`. The footer sits at the right with an 8 px gap between the two chips.
- **Auth popover:** opens above the chip (bottom 36 px, right-aligned, 168 px wide), `--ui-bg`, 1 px `--ui-border`, `--rc-shadow-pop`, 4 px padding, `role="menu"` named "Language". Rows are 32 px (44 px on mobile), 14 px text, `menuitemradio` with `lang`, a 16 px check icon at the right edge on the current one. Choosing a row applies the language and closes the popover; Escape or an outside click closes it without changing anything.
- The choice persists across reloads and applies to every screen at once.
## Added with roles, invite actions and Settings ## Added with roles, invite actions and Settings
@@ -792,7 +802,7 @@ Exact values live in `source/AppFrame.dc.html` (the Settings and Home states). C
| Component | Nuxt UI base | Props | Emits | Used in | | Component | Nuxt UI base | Props | Emits | Used in |
|---|---|---|---|---| |---|---|---|---|---|
| UpdatesCard | custom card, UProgress, UButton, UCollapsible | status: UpdateStatus, role | check, update, restart, copy-command | Settings | | UpdatesCard | custom card, UProgress, UButton, UCollapsible | status: UpdateStatus, role | check, update, restart, copy-command | Settings |
| UpdateStatusBlock | custom (polite live region) | tone, icon, title, text, meta | none | UpdatesCard | | UpdateStatusBlock | custom (polite live region) | tone, icon, title, text, meta, pendingTag?, action? (in-block Restart, Ready only) | none | UpdatesCard |
| UpdateProgress | UProgress | label, text, value or indeterminate | none | UpdatesCard | | UpdateProgress | UProgress | label, text, value or indeterminate | none | UpdatesCard |
| ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard | | ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard |
| ServerCommand | UButton, code block | line | copy | UpdatesCard (Not prepared) | | ServerCommand | UButton, code block | line | copy | UpdatesCard (Not prepared) |
+5 -1
View File
@@ -48,8 +48,12 @@ Desktop: a 224 px popover above the chip, aligned to its left. Mobile: the accou
| Header | The name in semibold (display name, or the username), and the email in muted 12 px (14 px on mobile, with a 36 px initials avatar beside them) | | Header | The name in semibold (display name, or the username), and the email in muted 12 px (14 px on mobile, with a 36 px initials avatar beside them) |
| Item | Profile (circle-user icon), highlighted while on `/profile` | | Item | Profile (circle-user icon), highlighted while on `/profile` |
| Separator | | | Separator | |
| Label | "Theme": 11 px, weight 500, uppercase, letter-spacing .04em, muted |
| Items | System, Light, Dark: a radio-style group, the current choice carries a check icon at the right edge; choosing one applies the theme at once and keeps the menu open | | Items | System, Light, Dark: a radio-style group, the current choice carries a check icon at the right edge; choosing one applies the theme at once and keeps the menu open |
| Separator | | | Separator | |
| Label | "Language", styled like "Theme" |
| Items | English, Français (each in its own language, with `lang`): a radio-style group with a check on the current one; choosing one applies the language at once and keeps the menu open |
| Separator | |
| Item | Log out (log-out icon) | | Item | Log out (log-out icon) |
Avatar initials come from the first letters of the first two words of the display name, uppercased ("Marta Alves" gives MA). Without a display name they come from the username. Avatar initials come from the first letters of the first two words of the display name, uppercased ("Marta Alves" gives MA). Without a display name they come from the username.
@@ -64,7 +68,7 @@ The board's menu now carries System, Light and Dark, matching the shipped UserMe
- Breadcrumbs: Home (mobile title "Home") - Breadcrumbs: Home (mobile title "Home")
- Page behind the menu: Home, default - Page behind the menu: Home, default
- Menu open, as described above, with the System, Light and Dark group between Profile and Log out. The board shows Light checked - Menu open, as described above, with the Theme group (System, Light, Dark) and the Language group (English, Français) between Profile and Log out. The board shows Light and English checked
### Loading ### Loading
+8 -6
View File
@@ -20,7 +20,9 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
| Part | Content | | Part | Content |
|---|---| |---|---|
| Status block | A polite live region (`aria-live="polite"`): 14 px padding, 12 px gap, an 18 px icon, a semibold title, the text, and a muted `meta` line. Background and icon color follow the tone: neutral `--ui-bg-muted`, success, info, warning, error (`--rc-<tone>-soft` fill, `--ui-<tone>` icon). | | Status block | A polite live region (`aria-live="polite"`): 14 px padding, 12 px gap, an 18 px icon, a semibold title, the text, and a muted `meta` line. Background and icon color follow the tone: neutral `--ui-bg-muted`, success, info, warning, error (`--rc-<tone>-soft` fill, `--ui-<tone>` icon). The title row is a flex row with an 8 px gap that wraps. In "Ready to install" it carries a **Pending tag** after the title and the block holds the **Restart** button (see below). |
| Pending tag | Only in "Ready to install", right after the title: 20 px high, 0 6 px padding, IBM Plex Mono 11 px medium, `--ui-bg` fill, `--ui-warning` text and 1 px `--ui-warning` border, square corners, text "Pending". |
| In-block button | Only in "Ready to install", "Restart: confirm" and "Restart: request failed": the primary Restart button sits inside the status block, under the `meta` line, after 8 px of extra top padding, left-aligned (content width, 14 px side padding, 32 px high, 44 px on mobile, not stretched). The buttons row under the block is not drawn in these states. The hint stays under the card. |
| Progress | A `progressbar` with a value (`aria-valuenow`) while downloading, labelled for example "Downloading version 2.3.0", with the text `42% · 11 of 26 MB`. Indeterminate while verifying (35 % wide moving bar), labelled "Checking the download", text "Almost there". Announced at 25 % steps and when the step changes, never on every tick. | | Progress | A `progressbar` with a value (`aria-valuenow`) while downloading, labelled for example "Downloading version 2.3.0", with the text `42% · 11 of 26 MB`. Indeterminate while verifying (35 % wide moving bar), labelled "Checking the download", text "Almost there". Announced at 25 % steps and when the step changes, never on every tick. |
| Release notes | A button "What's new in 2.3.0" with `aria-expanded` and a chevron, collapsed by default. Open: "Released 24 Sep 2026" and a plain-text list. | | Release notes | A button "What's new in 2.3.0" with `aria-expanded` and a chevron, collapsed by default. Open: "Released 24 Sep 2026" and a plain-text list. |
| Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. | | Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. |
@@ -28,7 +30,7 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
| Hint | 12 px muted line under the buttons. | | Hint | 12 px muted line under the buttons. |
| Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". | | Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". |
Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary when an update is ready, otherwise secondary), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it. Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary and inside the status block when an update is ready, otherwise secondary in the buttons row), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it.
## Copy ## Copy
@@ -40,7 +42,7 @@ Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`down
| Available | `circle-arrow-up`, info: "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart."; "Update"; hint "Takes about 2 minutes. Your site stays online." | | Available | `circle-arrow-up`, info: "Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart."; "Update"; hint "Takes about 2 minutes. Your site stays online." |
| Downloading | `download`, info: "Downloading version 2.3.0…" / "You can leave this page. The download continues and your site stays online."; "Updating…" disabled | | Downloading | `download`, info: "Downloading version 2.3.0…" / "You can leave this page. The download continues and your site stays online."; "Updating…" disabled |
| Verifying | `shield-check`, info: "Checking the download…" / "Making sure the file is complete and safe to install. This takes a few seconds." | | Verifying | `shield-check`, info: "Checking the download…" / "Making sure the file is complete and safe to install. This takes a few seconds." |
| Ready | `circle-check`, success: "Version 2.3.0 is ready to install" / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "Nothing has changed yet."; "Restart"; hint "Not now? Leave this page. The update waits here, and the banner on Home reminds you." | | Ready | `clock`, warning, with the "Pending" tag after the title: "Version 2.3.0 is ready to install" / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "Nothing has changed yet."; "Restart" (primary, inside the block); hint "Not now? Leave this page. The update waits here, and the banner on Home reminds you." |
| Done | "You're on version 2.3.0" / "The update is installed and your site is running again." / "Updated today at 09:52"; link "Read the release notes"; toast `circle-check` "Update installed" / "RenewCMS is now on version 2.3.0." | | Done | "You're on version 2.3.0" / "The update is installed and your site is running again." / "Updated today at 09:52"; link "Read the release notes"; toast `circle-check` "Update installed" / "RenewCMS is now on version 2.3.0." |
| Rolled back | `triangle-alert`, warning: "The update didn't install" / "RenewCMS put the previous version back, so your site is running as before. Nothing was lost." / "What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update."; "Try again"; toast (error, stays) `circle-x` "The update didn't install" / "Your site is back on v2.2.0-beta." | | Rolled back | `triangle-alert`, warning: "The update didn't install" / "RenewCMS put the previous version back, so your site is running as before. Nothing was lost." / "What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update."; "Try again"; toast (error, stays) `circle-x` "The update didn't install" / "Your site is back on v2.2.0-beta." |
| Download failed | `circle-x`, error: "The download didn't finish" / "The connection dropped before the file was complete. Nothing was changed and your site is running as before."; "Try again" | | Download failed | `circle-x`, error: "The download didn't finish" / "The connection dropped before the file was complete. Nothing was changed and your site is running as before."; "Try again" |
@@ -105,9 +107,9 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o
| Update available, notes open | "What's new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text. | | Update available, notes open | "What's new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text. |
| Downloading | Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick. | | Downloading | Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick. |
| Verifying | The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download". | | Verifying | The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download". |
| Ready to install | The file passed its checks. Restart becomes the primary action. "Restart later" is closing the dialog or leaving the page. | | Ready to install | The file passed its checks. The card turns warning (orange) with a "Pending" tag and a `clock` icon, and Restart, the primary action, sits inside it. "Restart later" is closing the dialog or leaving the page. |
| Restart: confirm | Restart pressed. Same dialog behaviour as the delete confirm: focus moves to the first button, Escape and "Restart later" close it, and focus returns to Restart. The confirm button is neutral, not red, because nothing is lost. | | Restart: confirm | Restart pressed. The card behind the dialog keeps the Ready look. Same dialog behaviour as the delete confirm: focus moves to the first button, Escape and "Restart later" close it, and focus returns to Restart. The confirm button is neutral, not red, because nothing is lost. |
| Restart: request failed | 500 or network failure before the restart began. The site is still running. | | Restart: request failed | 500 or network failure before the restart began. The site is still running. The card behind the dialog keeps the Ready look. |
| Restarting to install | Full-page overlay, so it survives the server going away. The page polls until the site answers, then reloads. It is a live region announced once as "Installing version 2.3.0. Your site is restarting." | | Restarting to install | Full-page overlay, so it survives the server going away. The page polls until the site answers, then reloads. It is a live region announced once as "Installing version 2.3.0. Your site is restarting." |
| Restarting, taking longer | No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back. | | Restarting, taking longer | No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back. |
| Update done | The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates. | | Update done | The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates. |
+4 -4
View File
@@ -18,7 +18,7 @@
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if> <sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if> <sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
</div> </div>
<sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if> <sc-if value="{{ deskMenu }}"><div role="menu" aria-label="Account" style="position:absolute;left:12px;bottom:56px;z-index:40;width:224px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div style="padding:8px 8px 10px;display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ who }}</span><span style="font-size:12px;color:var(--ui-text-muted)">marta@renew.example</span></div><div data-act="nav-profile" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:6px 8px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:10px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></sc-if>
</sc-if> </sc-if>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column"> <div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}"> <sc-if value="{{ desk }}" hint-placeholder-val="{{ true }}">
@@ -233,7 +233,7 @@
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div> <div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div>
<div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"> <div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px">
<span id="updates-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);outline:{{ s.upd.focus }};outline-offset:4px">Updates</span> <span id="updates-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted);outline:{{ s.upd.focus }};outline-offset:4px">Updates</span>
<div aria-live="polite" style="display:flex;gap:12px;padding:14px;background:{{ s.upd.bg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:{{ s.upd.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat"></span></span><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><span style="font:600 14px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.upd.title }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ s.upd.text }}</span><sc-if value="{{ s.upd.bar }}"><div style="display:flex;flex-direction:column;gap:6px;padding-top:8px"><div role="progressbar" aria-label="{{ s.upd.barLabel }}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ s.upd.pct }}" style="height:6px;background:var(--ui-bg-accented)"><div style="height:6px;width:{{ s.upd.barW }};margin-left:{{ s.upd.barL }};background:var(--ui-primary)"></div></div><span style="font:400 12px/16px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.upd.barText }}</span></div></sc-if><sc-if value="{{ s.upd.meta }}"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.meta }}</span></sc-if></div></div> <div aria-live="polite" style="display:flex;gap:12px;padding:14px;background:{{ s.upd.bg }}"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:{{ s.upd.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.icon }}.svg') center / contain no-repeat"></span></span><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px"><span style="font:600 14px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.upd.title }}</span><sc-if value="{{ s.upd.pendingTag }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-bg);color:var(--ui-warning);border:1px solid var(--ui-warning);font:500 11px 'IBM Plex Mono',monospace">Pending</span></sc-if></div><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ s.upd.text }}</span><sc-if value="{{ s.upd.bar }}"><div style="display:flex;flex-direction:column;gap:6px;padding-top:8px"><div role="progressbar" aria-label="{{ s.upd.barLabel }}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ s.upd.pct }}" style="height:6px;background:var(--ui-bg-accented)"><div style="height:6px;width:{{ s.upd.barW }};margin-left:{{ s.upd.barL }};background:var(--ui-primary)"></div></div><span style="font:400 12px/16px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ s.upd.barText }}</span></div></sc-if><sc-if value="{{ s.upd.meta }}"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.meta }}</span></sc-if><sc-if value="{{ s.upd.inBtn }}"><div style="display:flex;padding-top:8px"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ s.upd.inBtn.bg }};color:{{ s.upd.inBtn.fg }};border:1px solid {{ s.upd.inBtn.bd }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.inBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.inBtn.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.inBtn.t }}</span></div></sc-if></div></div>
<sc-if value="{{ s.upd.notes }}"><div style="display:flex;flex-direction:column;gap:10px"><span role="button" aria-expanded="{{ s.upd.notes.open }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;min-height:{{ btnH }};font-weight:500;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat"></span>What's new in {{ s.upd.notes.ver }}</span><sc-if value="{{ s.upd.notes.open }}"><div style="display:flex;flex-direction:column;gap:8px;padding:0 0 0 20px"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.notes.date }}</span><sc-for list="{{ s.upd.notes.items }}" as="it" hint-placeholder-count="3"><div style="display:flex;gap:8px"><span aria-hidden="true" style="flex-shrink:0;width:4px;height:4px;margin-top:8px;background:var(--ui-text-muted)"></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ it.t }}</span></div></sc-for><span style="align-self:flex-start;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">Read the full release notes</span></div></sc-if></div></sc-if> <sc-if value="{{ s.upd.notes }}"><div style="display:flex;flex-direction:column;gap:10px"><span role="button" aria-expanded="{{ s.upd.notes.open }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;min-height:{{ btnH }};font-weight:500;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.notes.chev }}.svg') center / contain no-repeat"></span>What's new in {{ s.upd.notes.ver }}</span><sc-if value="{{ s.upd.notes.open }}"><div style="display:flex;flex-direction:column;gap:8px;padding:0 0 0 20px"><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.notes.date }}</span><sc-for list="{{ s.upd.notes.items }}" as="it" hint-placeholder-count="3"><div style="display:flex;gap:8px"><span aria-hidden="true" style="flex-shrink:0;width:4px;height:4px;margin-top:8px;background:var(--ui-text-muted)"></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ it.t }}</span></div></sc-for><span style="align-self:flex-start;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">Read the full release notes</span></div></sc-if></div></sc-if>
<sc-if value="{{ s.upd.cmd }}"><div style="display:flex;flex-direction:column;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">For whoever manages the server</span><div style="display:flex;flex-direction:{{ upCmdDir }};align-items:{{ upBtnAlign }};gap:8px"><code style="flex:1;min-width:0;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:22px">{{ s.upd.cmd.line }}</code><span role="button" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ s.upd.cmd.fg }};font-weight:500"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.cmd.t }}</span></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.cmd.note }}</span></div></sc-if> <sc-if value="{{ s.upd.cmd }}"><div style="display:flex;flex-direction:column;gap:8px"><span style="font-weight:500;color:var(--ui-text-highlighted)">For whoever manages the server</span><div style="display:flex;flex-direction:{{ upCmdDir }};align-items:{{ upBtnAlign }};gap:8px"><code style="flex:1;min-width:0;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:22px">{{ s.upd.cmd.line }}</code><span role="button" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ s.upd.cmd.fg }};font-weight:500"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.upd.cmd.icon }}.svg') center / contain no-repeat"></span>{{ s.upd.cmd.t }}</span></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.upd.cmd.note }}</span></div></sc-if>
<sc-if value="{{ s.upd.btns }}"><div style="display:flex;flex-direction:{{ upBtnDir }};align-items:{{ upBtnAlign }};gap:8px"><sc-for list="{{ s.upd.btns }}" as="b" hint-placeholder-count="1"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ b.bg }};color:{{ b.fg }};border:1px solid {{ b.bd }};opacity:{{ b.op }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat"></span>{{ b.t }}</span></sc-for></div></sc-if> <sc-if value="{{ s.upd.btns }}"><div style="display:flex;flex-direction:{{ upBtnDir }};align-items:{{ upBtnAlign }};gap:8px"><sc-for list="{{ s.upd.btns }}" as="b" hint-placeholder-count="1"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;background:{{ b.bg }};color:{{ b.fg }};border:1px solid {{ b.bd }};opacity:{{ b.op }};font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ b.icon }}.svg') center / contain no-repeat"></span>{{ b.t }}</span></sc-for></div></sc-if>
@@ -280,7 +280,7 @@
</div> </div>
</div> </div>
<sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if> <sc-if value="{{ mobMenu }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="menu" aria-label="Account" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px"><div style="margin:0 auto 8px;width:36px;height:4px;background:var(--ui-bg-accented)"></div><div style="padding:8px 16px 14px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ui-border);margin-bottom:4px"><span style="width:36px;height:36px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 13px 'IBM Plex Sans',sans-serif">{{ ini }}</span><div style="display:flex;flex-direction:column;min-width:0"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ who }}</span><span style="color:var(--ui-text-muted)">marta@renew.example</span></div></div><div data-act="nav-profile" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;background:{{ pfItemBg }};color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-user.svg') center / contain no-repeat"></span>Profile</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Theme</div><sc-for list="{{ themeSeg }}" as="m" hint-placeholder-count="3"><div data-act="{{ m.act }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div style="padding:8px 16px 2px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Language</div><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:18px;height:18px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="logout" role="menuitem" style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;font-size:15px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/log-out.svg') center / contain no-repeat"></span>Log out</div></div></div></sc-if>
<sc-if value="{{ s.overlay }}"><div role="status" aria-live="polite" style="position:absolute;inset:0;z-index:70;background:var(--ui-bg-muted);display:flex;align-items:center;justify-content:center;padding:24px"><div style="width:100%;max-width:440px;display:flex;flex-direction:column;gap:16px;align-items:flex-start"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><div style="display:flex;flex-direction:column;gap:6px"><span style="font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.overlay.title }}</span><span style="font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ s.overlay.text }}</span></div><div role="progressbar" aria-label="Restarting" style="width:100%;height:4px;background:var(--ui-bg-accented)"><div style="width:35%;height:4px;margin-left:{{ s.overlay.barL }};background:var(--ui-primary)"></div></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.overlay.meta }}</span><sc-if value="{{ s.overlay.btn }}"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.overlay.btn }}</span></sc-if></div></div></sc-if> <sc-if value="{{ s.overlay }}"><div role="status" aria-live="polite" style="position:absolute;inset:0;z-index:70;background:var(--ui-bg-muted);display:flex;align-items:center;justify-content:center;padding:24px"><div style="width:100%;max-width:440px;display:flex;flex-direction:column;gap:16px;align-items:flex-start"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><div style="display:flex;flex-direction:column;gap:6px"><span style="font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ s.overlay.title }}</span><span style="font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">{{ s.overlay.text }}</span></div><div role="progressbar" aria-label="Restarting" style="width:100%;height:4px;background:var(--ui-bg-accented)"><div style="width:35%;height:4px;margin-left:{{ s.overlay.barL }};background:var(--ui-primary)"></div></div><span style="font-size:12px;color:var(--ui-text-muted)">{{ s.overlay.meta }}</span><sc-if value="{{ s.overlay.btn }}"><span role="button" style="height:{{ btnH }};padding:0 14px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500">{{ s.overlay.btn }}</span></sc-if></div></div></sc-if>
@@ -435,7 +435,7 @@ class Component extends DCLogic {
const edTitle = e.title || 'How we price a bathroom renovation' const edTitle = e.title || 'How we price a bathroom renovation'
return { return {
cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn, cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)', langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)', chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)',
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading, isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: m ? '16px' : '20px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar, upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: m ? '16px' : '20px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
+6 -3
View File
@@ -54,16 +54,17 @@
<sc-if value="{{ hasFooter }}"> <sc-if value="{{ hasFooter }}">
<div style="width:{{ w }};max-width:100%;padding:{{ footPad }};display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13px;color:var(--ui-text-muted)"> <div style="width:{{ w }};max-width:100%;padding:{{ footPad }};display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13px;color:var(--ui-text-muted)">
<span style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"><sc-if value="{{ s.footer }}"><span>{{ s.footer.pre }}</span><span data-act="footer" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px">{{ s.footer.link }}</span></sc-if><sc-if value="{{ s.pending }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">API pending</span></sc-if></span> <span style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"><sc-if value="{{ s.footer }}"><span>{{ s.footer.pre }}</span><span data-act="footer" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px">{{ s.footer.link }}</span></sc-if><sc-if value="{{ s.pending }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">API pending</span></sc-if></span>
<span style="height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat"></span>System</span> <span style="position:relative;display:flex;align-items:center;gap:8px"><sc-if value="{{ langOpen }}"><div role="menu" aria-label="Language" style="position:absolute;right:0;bottom:36px;z-index:10;width:168px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ langSeg }}" as="m" hint-placeholder-count="2"><div data-act="{{ m.act }}" lang="{{ m.code }}" role="menuitemradio" aria-checked="{{ m.on }}" style="height:{{ optH }};padding:0 8px;display:flex;align-items:center;justify-content:space-between;font-size:14px;color:var(--ui-text-highlighted)">{{ m.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ m.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></div></sc-for></div></sc-if><span data-act="langmenu" role="button" aria-haspopup="menu" aria-expanded="{{ langOpen }}" aria-label="Language: {{ langName }}" style="height:{{ chipH }};padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);background:{{ langBg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/languages.svg') center / contain no-repeat"></span>{{ langName }}</span><span style="height:{{ chipH }};padding:0 8px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/monitor.svg') center / contain no-repeat"></span>System</span></span>
</div> </div>
</sc-if> </sc-if>
</div> </div>
</x-dc> </x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:480,&quot;height&quot;:620},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}"> <script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:480,&quot;height&quot;:620},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;lang&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;en&quot;,&quot;fr&quot;],&quot;default&quot;:&quot;en&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;langOpen&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;},&quot;mobile&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic { class Component extends DCLogic {
renderVals() { renderVals() {
const s = this.props.s || {} const s = this.props.s || {}
const m = !!this.props.mobile const m = !!this.props.mobile
const lang = this.props.lang || s.lang || 'en'
const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' }) const tone = t => ({ bg: 'var(--rc-' + t + '-soft)', fg: 'var(--ui-' + t + ')' })
const notice = s.notice ? { ...s.notice, ...tone(s.notice.tone) } : null const notice = s.notice ? { ...s.notice, ...tone(s.notice.tone) } : null
const result = s.result ? { ...s.result, ...tone(s.result.tone), actions: (s.result.actions || []).map((a, i) => i === 0 && !a.secondary ? { act: 'result-' + i, t: a.t, bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: '1px solid var(--ui-primary)' } : { act: 'result-' + i, t: a.t, bg: 'var(--ui-bg)', fg: 'var(--ui-text-highlighted)', bd: '1px solid var(--ui-border-accented)' }) } : null const result = s.result ? { ...s.result, ...tone(s.result.tone), actions: (s.result.actions || []).map((a, i) => i === 0 && !a.secondary ? { act: 'result-' + i, t: a.t, bg: 'var(--ui-primary)', fg: 'var(--ui-bg)', bd: '1px solid var(--ui-primary)' } : { act: 'result-' + i, t: a.t, bg: 'var(--ui-bg)', fg: 'var(--ui-text-highlighted)', bd: '1px solid var(--ui-border-accented)' }) } : null
@@ -87,7 +88,9 @@ class Component extends DCLogic {
return { return {
s, notice, result, fields, s, notice, result, fields,
hasForm: fields.length > 0 && !!s.submit, hasForm: fields.length > 0 && !!s.submit,
hasFooter: !!(s.footer || s.pending || !m), hasFooter: true,
langOpen: !!(this.props.langOpen ?? s.langOpen), langName: lang === 'fr' ? 'Français' : 'English', langBg: this.props.langOpen ?? s.langOpen ? 'var(--ui-bg-elevated)' : 'transparent', chipH: m ? '44px' : '28px', optH: m ? '44px' : '32px',
langSeg: [['en','English'],['fr','Français']].map(([k,t]) => ({ act: 'lang-' + k, code: k, t, on: lang === k, op: lang === k ? 1 : 0 })),
submitOpacity: s.submit && s.submit.disabled && !s.submit.loading ? '0.4' : '1', submitOpacity: s.submit && s.submit.disabled && !s.submit.loading ? '0.4' : '1',
w: m ? '100%' : '400px', w: m ? '100%' : '400px',
border: m ? '0' : '1px solid var(--ui-border)', border: m ? '0' : '1px solid var(--ui-border)',
@@ -46,7 +46,7 @@
<main ref="{{ stageRef }}" style="flex:1;min-width:0;height:100vh;overflow:auto;display:flex;align-items:center;justify-content:center;padding:24px"> <main ref="{{ stageRef }}" style="flex:1;min-width:0;height:100vh;overflow:auto;display:flex;align-items:center;justify-content:center;padding:24px">
<div style="flex-shrink:0;width:{{ boxW }};height:{{ boxH }};box-shadow:0 8px 40px rgb(0 0 0 / 0.15);overflow:hidden"> <div style="flex-shrink:0;width:{{ boxW }};height:{{ boxH }};box-shadow:0 8px 40px rgb(0 0 0 / 0.15);overflow:hidden">
<div onClick="{{ onStage }}" class="{{ hsClass }}" style="width:{{ devW }};height:{{ devH }};zoom:{{ zoom }};position:relative;overflow:hidden;background:var(--ui-bg-muted)"> <div onClick="{{ onStage }}" class="{{ hsClass }}" style="width:{{ devW }};height:{{ devH }};zoom:{{ zoom }};position:relative;overflow:hidden;background:var(--ui-bg-muted)">
<sc-if value="{{ isAuth }}"><div style="width:100%;height:100%;overflow:hidden;background:{{ authBg }};padding-top:{{ authTop }};display:flex;flex-direction:column;align-items:center"><dc-import name="AuthCard" s="{{ authS }}" mobile="{{ mobile }}" hint-size="400px,520px"></dc-import></div></sc-if> <sc-if value="{{ isAuth }}"><div style="width:100%;height:100%;overflow:hidden;background:{{ authBg }};padding-top:{{ authTop }};display:flex;flex-direction:column;align-items:center"><dc-import name="AuthCard" lang="{{ lang }}" lang-open="{{ langOpen }}" s="{{ authS }}" mobile="{{ mobile }}" hint-size="400px,520px"></dc-import></div></sc-if>
<sc-if value="{{ isApp }}"><dc-import name="AppFrame" s="{{ appS }}" mobile="{{ mobile }}" hint-size="100%,100%"></dc-import></sc-if> <sc-if value="{{ isApp }}"><dc-import name="AppFrame" s="{{ appS }}" mobile="{{ mobile }}" hint-size="100%,100%"></dc-import></sc-if>
<sc-if value="{{ isEmail }}"><div style="width:100%;height:100%;display:flex;flex-direction:column;background:var(--ui-bg)"><div style="flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ emailSubject }}</span><span style="color:var(--ui-text-muted)">From RenewCMS · to marta@renew.example</span></div><iframe title="Email" srcDoc="{{ emailHtml }}" style="flex:1;width:100%;border:0"></iframe></div></sc-if> <sc-if value="{{ isEmail }}"><div style="width:100%;height:100%;display:flex;flex-direction:column;background:var(--ui-bg)"><div style="flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ emailSubject }}</span><span style="color:var(--ui-text-muted)">From RenewCMS · to marta@renew.example</span></div><iframe title="Email" srcDoc="{{ emailHtml }}" style="flex:1;width:100%;border:0"></iframe></div></sc-if>
<sc-if value="{{ mnav }}"> <sc-if value="{{ mnav }}">
@@ -187,6 +187,9 @@ class Component extends DCLogic {
act(a) { act(a) {
const s = this.state const s = this.state
if (a === 'usermenu') return this.setState({ userMenu: !s.userMenu }) if (a === 'usermenu') return this.setState({ userMenu: !s.userMenu })
if (a === 'langmenu') return this.setState({ langOpen: !s.langOpen })
if (a && a.startsWith('lang-')) return this.setState({ lang: a.slice(5), langOpen: false })
if (s.langOpen) this.setState({ langOpen: false })
if (a && a.startsWith('theme-')) return this.setState({ mode: a.slice(6) }) if (a && a.startsWith('theme-')) return this.setState({ mode: a.slice(6) })
if (s.userMenu) this.setState({ userMenu: false }) if (s.userMenu) this.setState({ userMenu: false })
if (a === 'pf-reset') return this.setState({ pfReset: 'sent' }) if (a === 'pf-reset') return this.setState({ pfReset: 'sent' })
@@ -428,8 +431,8 @@ class Component extends DCLogic {
rootRef: this.rootRef, stageRef: this.stageRef, rootRef: this.rootRef, stageRef: this.stageRef,
devices: devs, modes, flows, hint, tools, devices: devs, modes, flows, hint, tools,
hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }), hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }),
mobile, isAuth, isApp, isEmail, mobile, isAuth, isApp, isEmail, lang: s.lang || 'en', langOpen: !!s.langOpen,
authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode } : {}, authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode, lang: s.lang || 'en' } : {},
authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px', authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px',
emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account', emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account',
devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px', devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px',
@@ -120,7 +120,7 @@ class Component extends DCLogic {
const pk = (o = {}, x = {}) => MS({ kind: 'picker', picker: true, pk: { tab: 'Library', ...o }, ...x }) const pk = (o = {}, x = {}) => MS({ kind: 'picker', picker: true, pk: { tab: 'Library', ...o }, ...x })
const TB = { neutral: 'var(--ui-bg-muted)', success: 'var(--rc-success-soft)', info: 'var(--rc-info-soft)', warning: 'var(--rc-warning-soft)', error: 'var(--rc-error-soft)' } const TB = { neutral: 'var(--ui-bg-muted)', success: 'var(--rc-success-soft)', info: 'var(--rc-info-soft)', warning: 'var(--rc-warning-soft)', error: 'var(--rc-error-soft)' }
const B = (t, kind, icon, dis) => ({ t, icon, bg: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: kind === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', bd: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-border-accented)', op: dis ? '0.4' : '1' }) const B = (t, kind, icon, dis) => ({ t, icon, bg: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: kind === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', bd: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-border-accented)', op: dis ? '0.4' : '1' })
const UPD = (o) => { const tone = o.tone || 'neutral'; return { v: 'v2.2.0-beta', focus: 'none', ...o, fg: tone === 'neutral' ? 'var(--ui-text-toned)' : 'var(--ui-' + tone + ')', bg: TB[tone], bar: o.pct !== undefined || !!o.indet, barW: o.indet ? '35%' : (o.pct || 0) + '%', barL: o.indet ? '30%' : '0', pct: o.indet ? undefined : o.pct } } const UPD = (o) => { const rdy = o.title === 'Version 2.3.0 is ready to install' && o.btns && o.btns.length; if (rdy) o = { ...o, tone: 'warning', icon: 'clock', pendingTag: true, inBtn: o.btns[0], btns: undefined }; const tone = o.tone || 'neutral'; return { v: 'v2.2.0-beta', focus: 'none', ...o, fg: tone === 'neutral' ? 'var(--ui-text-toned)' : 'var(--ui-' + tone + ')', bg: TB[tone], bar: o.pct !== undefined || !!o.indet, barW: o.indet ? '35%' : (o.pct || 0) + '%', barL: o.indet ? '30%' : '0', pct: o.indet ? undefined : o.pct } }
const NOTES = (open) => ({ open, chev: open ? 'chevron-down' : 'chevron-right', ver: '2.3.0', date: 'Released 24 Sep 2026', items: [{ t: 'Images upload faster, and large images no longer stall.' }, { t: 'Fixed a problem where a draft could show an old title after saving.' }, { t: 'Small improvements to the Users page.' }] }) const NOTES = (open) => ({ open, chev: open ? 'chevron-down' : 'chevron-right', ver: '2.3.0', date: 'Released 24 Sep 2026', items: [{ t: 'Images upload faster, and large images no longer stall.' }, { t: 'Fixed a problem where a draft could show an old title after saving.' }, { t: 'Small improvements to the Users page.' }] })
const SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) }) const SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) })
const LAST = 'Last checked today at 09:41' const LAST = 'Last checked today at 09:41'
+1
View File
@@ -29,3 +29,4 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.5.0-beta` | v2.5.0-beta - Media library & image picker | | `v2.5.0-beta` | v2.5.0-beta - Media library & image picker |
| `v2.6.0-beta` | v2.6.0-beta - Signed updates and browser restart | | `v2.6.0-beta` | v2.6.0-beta - Signed updates and browser restart |
| `v2.6.2-beta` | v2.6.2-beta - English and French interface | | `v2.6.2-beta` | v2.6.2-beta - English and French interface |
| `v2.7.0-beta` | v2.7.0-beta - Markdown and block article bodies |
+35
View File
@@ -0,0 +1,35 @@
This release lets your website read article bodies as markdown, HTML or structured blocks, and fixes text alignment in the editor.
## New
**Article body formats**
- Read an article body as markdown, HTML or a typed block array with the `format` query parameter, so a headless frontend can render it with markdown-it, comark or its own components.
- Keep text alignment in every format: markdown wraps centered, right-aligned and justified blocks in a `div` with an inline style, and the block array carries an `align` field.
- Keep plain text plain: text that merely looks like markdown, such as `# not a heading` or `- not a list`, is escaped in the markdown output.
- See the Integration page snippets and [the article API reference](../api/articles.md) for the full contract.
## Improvements and fixes
- Fix the text alignment buttons in the article editor, which were disabled and showed a blocked cursor. Paragraphs and headings can be aligned left, center, right or justified again, and unaligned text stays free of inline styles.
- The web interface requests HTML explicitly when it loads, saves, publishes, unpublishes or creates an article, so the editor and the image counts behave exactly as before.
## API
- Changed: `GET /v1/article/`, `GET /v1/article/{id}`, `POST /v1/article/`, `PATCH /v1/article/{id}`, `PATCH /v1/article/{id}/publish` and `PATCH /v1/article/{id}/unpublish` now return `body` as markdown by default. Pass `?format=html` to keep the previous HTML body.
- Every article object now includes `body_format` (`markdown`, `html` or `json`), which names the format of `body`.
- `?format=json` on the same routes returns `body` as a JSON array of typed blocks instead of a string.
- An unsupported `format` answers `400` before anything is read or written.
- Storage and the request bodies of `POST` and `PATCH` stay HTML.
## Under the hood
- Add the article body renderer port in the domain, with a markdown and JSON block adapter, and tests for every supported element.
- Update `golang.org/x/crypto` to v0.57.0 and add `golang.org/x/net` as a direct dependency for HTML parsing.
- Add `@tiptap/extension-text-align` to the web interface.
## Upgrading
- Replace the binary. No database migration or new environment variable is required.
- Clients that read an article `body` as HTML must add `?format=html` to their article requests, because the default is now markdown.
- A markdown renderer needs raw HTML enabled for text alignment to show, for example `html: true` in markdown-it.
+229
View File
@@ -0,0 +1,229 @@
Feature: Article body content negotiation on read
As a headless frontend developer
I want article routes to return the body as markdown (default), html or a typed json block array
So that my client can render it without handling editor HTML
Background:
Given article bodies are stored as Tiptap HTML
And storage, the POST /v1/article body and the PATCH /v1/article/{id} body stay HTML and are unchanged
And internal/domain/article defines BodyFormat with the values "markdown", "html" and "json"
And internal/domain/article defines the port BodyRenderer and the sentinel ErrUnsupportedBodyFormat
And ParseBodyFormat matches case-insensitively, treats an empty value as "markdown", and returns ErrUnsupportedBodyFormat otherwise
And the use cases receive the BodyRenderer through their constructors, the handler only reads the "format" query value, and only api/dependecyInjection.go builds the converter
And every article object in a response has the snake_case fields id, title, body, body_format, is_online, created_at, updated_at
And body_format is "markdown", "html" or "json" and always names the format of body
And for markdown and html, body is a JSON string; for json, body is a JSON array of blocks (not an escaped string)
And the covered routes are GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish and PATCH /v1/article/{id}/unpublish
And visibility is unchanged: anonymous and unverified callers see only online articles, and an offline article reads as 404 "Article not found"
And an unsupported format answers 400 with apperr.ArticleBodyFormatErrorMessage as plain text, before any article lookup or write
And an invalid id answers apperr.IdUint32ErrorMessage first, ahead of the format check
And a repeated "format" parameter uses its first value
And a renderer failure answers 500 "Internal server error"
And article 1 is online, titled "Backyard Renovation Update", with body "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
Scenario Outline: Single read negotiates the body format
When an anonymous client sends GET /v1/article/1<query>
Then the response status is 200
And "body" is <body>
And "body_format" is "<body_format>"
Examples:
| query | body | body_format |
| | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format= | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=markdown | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=MarkDown | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=html | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
| ?format=HTML | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
| ?format=json | [{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}] | json |
| ?format=JSON | the same array as ?format=json | json |
Scenario: List read negotiates every element
Given the repository also holds online article 3 with body "<p>Second</p>"
When an anonymous client sends GET /v1/article/
Then every element has body_format "markdown" and article 3 has body "Second"
When an anonymous client sends GET /v1/article/?format=html
Then every element has body_format "html" and its stored HTML as body
When an anonymous client sends GET /v1/article/?format=json
Then every element has body_format "json" and article 3 has body [{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]
Scenario Outline: An unsupported format is rejected
When an anonymous client sends GET <path>
Then the response status is 400
And the response body is apperr.ArticleBodyFormatErrorMessage followed by a newline
And no use case reads or renders an article
Examples:
| path |
| /v1/article/1?format=pdf |
| /v1/article/?format=xml |
| /v1/article/9999?format=pdf |
| /v1/article/1?format=md |
Scenario: An invalid id is reported before an invalid format
When an anonymous client sends GET /v1/article/abc?format=pdf
Then the response status is 400
And the response body is apperr.IdUint32ErrorMessage followed by a newline
Scenario: The first repeated format parameter wins
When an anonymous client sends GET /v1/article/1?format=html&format=json
Then "body_format" is "html"
Scenario: Offline articles stay hidden whatever the format
Given article 2 is offline
When an anonymous client sends GET /v1/article/2?format=json
Then the response status is 404 and the body is "Article not found"
Scenario Outline: Authenticated routes negotiate the response body
Given a valid jwt cookie for a verified user with id 1
When the client sends <method> <path> with <request_body>
Then the response status is <status>
And "body_format" is "<body_format>"
And the stored body is the HTML that was sent, never markdown or json
Examples:
| method | path | request_body | status | body_format |
| POST | /v1/article/ | {"title":"Deck","body":"<p>A</p>"} | 201 | markdown |
| POST | /v1/article/?format=html | {"title":"Deck","body":"<p>A</p>"} | 201 | html |
| POST | /v1/article/?format=json | {"title":"Deck","body":"<p>A</p>"} | 201 | json |
| PATCH | /v1/article/1 | {"body":"<p>B</p>"} | 200 | markdown |
| PATCH | /v1/article/1?format=json | {"body":"<p>B</p>"} | 200 | json |
| PATCH | /v1/article/1/publish | | 200 | markdown |
| PATCH | /v1/article/1/unpublish?format=html | | 200 | html |
Scenario: An unsupported format on a write route changes nothing
Given a valid jwt cookie for a verified user with id 1
When the client sends PATCH /v1/article/1?format=pdf with {"body":"<p>B</p>"}
Then the response status is 400
And the stored body of article 1 is unchanged
When the client sends POST /v1/article/?format=pdf with {"title":"Deck","body":"<p>A</p>"}
Then the response status is 400 and no article is created
Scenario: The authenticated list includes offline articles in the negotiated format
Given a valid jwt cookie for a verified user and offline article 2
When the client sends GET /v1/article/?format=json
Then the array contains articles 1 and 2 with body_format "json"
Scenario: A converter failure answers 500
Given the BodyRenderer fails on its next call
When an anonymous client sends GET /v1/article/1
Then the response status is 500 and the body is "Internal server error"
Scenario Outline: Markdown fidelity for Tiptap output
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
Examples:
| html | markdown |
| <h1>T</h1><h3>S</h3> | # T\n\n### S |
| <p><strong>b</strong> <em>i</em></p> | **b** *i* |
| <p><a href="https://example.com/x">link</a></p> | [link](https://example.com/x) |
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | - a\n- b |
| <ol><li><p>a</p></li><li><p>b</p></li></ol> | 1. a\n2. b |
| <ul><li data-indent="2"><p>a</p></li></ul> | - a |
| <blockquote><p>q</p></blockquote> | > q |
| <pre><code class="language-go">x := 1</code></pre> | ```go\nx := 1\n``` |
| <hr> | --- |
| <img src="/static/uploadedImages/a.png" alt="A"> | ![A](/static/uploadedImages/a.png) |
| <p>ok</p><script>alert(1)</script><style>p{}</style> | ok |
| <p>x<span style="color:red">y</span></p> | xy |
| <p>use <code>go test</code></p> | use `go test` |
Scenario Outline: Json block shapes, one row per block and mark type
Given the stored body is <html>
When converted with the format "json"
Then the body is <json>
Examples:
| html | json |
| <h1>T</h1> | [{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}] |
| <h6>S</h6> | [{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}] |
| <p>plain</p> | [{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}] |
| <p></p> | [{"type":"paragraph","children":[]}] |
| <p><strong>b</strong></p> | [{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}] |
| <p><em>i</em></p> | [{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}] |
| <p><a href="https://example.com/x">l</a></p> | [{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}] |
| <p><a href="/a"><strong><em>x</em></strong></a></p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}] |
| <p>a<br>b</p> | [{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}] |
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}] |
| <ol><li><p>a</p></li></ol> | [{"type":"list","ordered":true,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}]]}] |
| <ul><li>bare</li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"bare","marks":[]}]}]]}] |
| <ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul> | a list item whose blocks are the paragraph "a" followed by a nested list block with ordered false and the item "n" |
| <ul><li data-indent="2"><p>a</p></li></ul> | the same output as without data-indent |
| <blockquote><p>q</p></blockquote> | [{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}] |
| <pre><code class="language-go">x := 1</code></pre> | [{"type":"code","language":"go","text":"x := 1"}] |
| <pre><code>raw</code></pre> | [{"type":"code","language":"","text":"raw"}] |
| <hr> | [{"type":"hr"}] |
| <img src="/static/uploadedImages/a.png" alt="A"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}] |
| <img src="/static/uploadedImages/a.png"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":""}] |
| <p>x<img src="/i.png" alt="I">y</p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}] |
| <p>x<span style="color:red">y</span></p> | [{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}] |
| <p>use <code>go test</code></p> | [{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}] |
| <p>ok</p><script>alert(1)</script><style>p{}</style> | [{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}] |
Scenario Outline: Text alignment survives the conversion
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
When converted with the format "json"
Then the body is <json>
Examples:
| html | markdown | json |
| <p style="text-align: center">a</p> | <div style="text-align: center">
a
</div> | [{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}] |
| <h2 style="text-align:right">T</h2> | <div style="text-align: right">
## T
</div> | [{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}] |
| <p style="text-align: JUSTIFY">a</p> | <div style="text-align: justify">
a
</div> | [{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}] |
| <p style="text-align: left">a</p> | a | [{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}] |
| <p style="text-align: start">a</p> | a | the same output as the left row |
| <p style="text-align: center"></p> | an empty string | [{"type":"paragraph","align":"center","children":[]}] |
Scenario Outline: Plain text that looks like markdown stays plain text
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
Examples:
| html | markdown |
| <p># not a heading</p> | \# not a heading |
| <p>#tag</p> | #tag |
| <p>- not a list</p> | \- not a list |
| <p>+ not a list</p> | \+ not a list |
| <p>&gt; not a quote</p> | \> not a quote |
| <p>1. not a list</p> | 1\. not a list |
| <p>2) not a list</p> | 2\) not a list |
| <p>---</p> | \--- |
| <p>a - b</p> | a - b |
| <p>a * b _c_</p> | a \* b \_c\_ |
| <p>a<br>- b</p> | a\n\- b |
Scenario: The editor lets the author align text
Given the article editor registers the TextAlign extension for paragraphs and headings
Then the left, center, right and justify toolbar buttons are enabled
And clicking center writes style="text-align: center;" on the current paragraph or heading
And paragraphs and headings that were never aligned carry no style attribute
Scenario: The SPA asks for html explicitly
Given no visual change is expected, so docs/design is not consulted
When EditorView loads an article, or ArticleView or HomeView load the list
Then each request is /v1/article/{id}?format=html or /v1/article/?format=html
And the draft save PATCH, the publish and unpublish PATCH and the create POST also carry ?format=html, so a converter failure cannot fail a save
And the editor and the image counts receive HTML exactly as before
Scenario: The documentation reflects the contract
Then docs/api has an article read page covering format, the markdown default, body_format, the json block schema, the 400 message and the breaking default change
And web/src/views/IntegrationView.vue snippets show ?format
And docs/releases has a note stating the default flip and that the SPA sends format=html
+46
View File
@@ -0,0 +1,46 @@
# QA procedure: article_body_format
Package: `test` (black-box). New files: `test/article_body_format_test.go`, `test/markdown_converter_test.go`, `test/json_blocks_converter_test.go`. No live server, no real SQLite file, no real SMTP.
## Setup
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` and `mockUserRepo`. Add a fake `fakeBodyRenderer` with a unique type name. It records each call's format and the html it received. For markdown it returns "MD:"+html. For html it returns the html unchanged. For json it returns `[{"type":"paragraph","children":[{"type":"text","text":"FAKE","marks":[]}]}]`. It has a failure switch.
3. Wire the real use cases over the fakes, passing the renderer. Update every existing test that builds these use cases for the new constructor signatures. Build `article.NewHandler(...)`.
4. Mount a fresh `chi.NewRouter()` mirroring `api/router.go` for `/v1/article`: `jwtauth.Verifier` on the group, GET `/` and `/{id}` public, and `Authenticator` + `RequireCurrentToken` + `RequireVerifiedUser` for POST, DELETE and PATCH.
5. Mint a jwt cookie for user_id 1 with `token.Encode(map[string]any{"user_id": uint32(1)})`. Seed a verified user. Seed articles 1 (online) and 2 (offline).
## HTTP checks (fake renderer)
6. Single read matrix: for each of none, `?format=`, markdown, MarkDown, html, HTML, json, JSON, assert status 200, `body_format`, the renderer's recorded format and the stored HTML passed in. For json, decode `body` and assert it is a JSON array (not a string).
7. List with none, `?format=html` and `?format=json`: every element has the matching `body_format`.
8. `format=pdf`, `xml` and `md` on single, list and unknown-id paths return 400 with `apperr.ArticleBodyFormatErrorMessage + "\n"`. The renderer and the repo are not called.
9. `GET /v1/article/abc?format=pdf` returns 400 with `apperr.IdUint32ErrorMessage + "\n"`.
10. `?format=html&format=json` returns html.
11. `GET /v1/article/2?format=json` anonymous returns 404 `"Article not found\n"`.
12. Write-route matrix with a cookie (POST, PATCH, publish, unpublish, each with none, html and json where listed): assert status, `body_format`, and that the repo holds the HTML that was sent.
13. `PATCH /v1/article/1?format=pdf` and `POST /v1/article/?format=pdf` with a cookie return 400. The repo is unchanged and no article is created.
14. Authenticated `GET /v1/article/?format=json` includes articles 1 and 2.
15. Renderer failure switch, then `GET /v1/article/1` returns 500 `"Internal server error\n"`.
## Domain
16. `ParseBodyFormat`: "", markdown, MARKDOWN, html, Html, json, JSON are accepted (empty returns markdown). pdf, md and " html" return `errors.Is(err, ErrUnsupportedBodyFormat)`.
## Converter tests (real library, real Tiptap HTML samples)
17. `test/markdown_converter_test.go`: every row of the markdown fidelity table, plus a combined document (heading, paragraph with marks, list, image, code block, quote, hr). Assert exact output. For format html, assert the input comes back byte-identical.
17b. Alignment and escaping: every row of the alignment and plain-text tables, for markdown and json, in `test/markdown_converter_test.go` and `test/json_blocks_converter_test.go`. `test/body_converter_documents_test.go` covers the combined hostile document (no raw script, style, iframe or span leaks in markdown or json), its block types, and code block text extraction.
18. `test/json_blocks_converter_test.go`: every row of the json shapes table. Assert by unmarshalling both sides and comparing with deep equality. This also asserts `marks` is `[]` rather than null or absent, `ordered` is a bool, and code `language` is "" when absent. Also include a combined document with nested list, blockquote and an image inside a paragraph. Assert that script and style content never appears, and that no raw HTML survives in any output for any format except html.
19. `test/json_blocks_converter_test.go` also asserts that html "" produces `[]`, and that the output is a non-null JSON array.
## SPA (Vitest, `task test:web`)
20. Mock `/v1/article/12?format=html` and assert `EditorView` requests that URL and loads the HTML body into the draft.
21. Assert `useArticles.listArticles` requests `/v1/article/?format=html`.
22. Assert the `ArticleView` image count is computed from the HTML.
23. Assert `useArticleDraft.save` sends PATCH `/v1/article/{id}?format=html` with the JSON body.
24. Reported as NOT RUN if there is no runner. No visual check applies, because no visual change is expected.
## Docs
25. Check that a docs/api article read page, the IntegrationView snippets and a docs/releases note exist as described.
## Manual verification
- In the embedded SPA, edit and save an article against a real SQLite file and confirm the stored body is still HTML.
- `curl` the public list and a single article with no format, `?format=html` and `?format=json` against a built binary.
- No SMTP involvement.
@@ -0,0 +1,172 @@
Feature: Language switcher conforms to the official design
Background:
Given the design in docs/design is the visual spec of record for LanguageSwitcher and UserMenu
And the interface supports exactly two languages, English and Français
And each language is always written in its own name, whatever the interface language
And the language rules of features/spa_internationalization.feature still apply
And the language control no longer uses a form select anywhere
And no language preference is sent to the server
Scenario Outline: Account menu lists Profile, Theme, Language and Log out in design order
Given a signed-in user opens the account menu in the <surface>
Then the entries appear in this order
| Profile |
| separator |
| "Theme" section label |
| System, Light, Dark |
| separator |
| "Language" section label |
| English, Français |
| separator |
| Log out |
And the "Theme" and "Language" labels are 11 px, weight 500, uppercase, letter-spacing 0.04em and --ui-text-muted
And the label padding is <label_padding>
And the menu is <size>
And every item row is <row_height> high
Examples:
| surface | label_padding | size | row_height |
| desktop UserMenu popover | 6px 8px 2px | a 224 px popover above the chip | 32 px |
| mobile AccountMenuSheet | 8px 16px 2px | a bottom sheet with grab handle | 48 px |
Scenario Outline: Language rows are a radio group naming each language in its own name
Given the interface language is <locale>
When the account menu is open in the <surface>
Then the "Language" label names a group of two menuitemradio rows
And the rows read "English" with lang "en" and "Français" with lang "fr"
And the row for <locale_name> has aria-checked "true" and a check icon at the right edge
And the other row has aria-checked "false" and no check icon
And no Nuxt UI select, combobox or languages icon row is present in the menu
Examples:
| surface | locale | locale_name |
| desktop UserMenu popover | en | English |
| desktop UserMenu popover | fr | Français |
| mobile AccountMenuSheet | en | English |
| mobile AccountMenuSheet | fr | Français |
Scenario Outline: Theme rows are radio items that keep the menu open
Given the theme preference is <preference>
When the account menu is open in the <surface>
Then System, Light and Dark are menuitemradio rows in a group named "Theme"
And only the <preference> row has aria-checked "true" and a check icon
When the user chooses another theme row
Then the theme applies at once and the menu stays open
Examples:
| surface | preference |
| desktop UserMenu popover | light |
| mobile AccountMenuSheet | dark |
Scenario Outline: Choosing a language in the account menu applies it at once and keeps the menu open
Given the interface language is en and the user is on /profile with an unsaved display name "Alice B."
And the account menu is open in the <surface>
When the user chooses "Français"
Then all interface text, including the menu labels "Thème" and "Langue", is French
And the document lang is "fr" and localStorage "renewcms-language" is "fr"
And the Français row is checked and the menu stays open
And the route, session, theme and unsaved display name are unchanged
And no reload, navigation or /v1 request occurs
When the user chooses "English"
Then the interface is English, the document lang is "en" and the menu stays open
Examples:
| surface |
| desktop UserMenu popover |
| mobile AccountMenuSheet |
Scenario Outline: Account menu keyboard and dismissal
Given the account menu is open in the <surface>
When the user moves with ArrowDown and ArrowUp
Then focus visits Profile, the theme rows, the language rows and Log out in visual order and wraps at the ends
When the user presses Enter or Space on a language row
Then that language is applied and the menu stays open
When the user presses Escape or clicks outside
Then the menu closes without changing the language
Examples:
| surface |
| desktop UserMenu popover |
| mobile AccountMenuSheet |
Scenario Outline: Auth footer shows the language chip beside the theme chip
Given the user is signed out on <route>
And the interface language is en
Then the footer outside the card contains the language chip and the System theme chip aligned right with an 8 px gap
And any existing footer slot content stays to the left of the chips
And the chip shows the lucide languages icon at 14 px followed by "English"
And the chip has a 1 px --ui-border-accented border, --ui-text-toned text, aria-haspopup "menu", aria-expanded "false" and aria-label "Language: English"
And no language select is rendered in the card or the footer
Examples:
| route |
| /login |
| /register |
| /register/pending |
| /register/validate |
| /register/reset/request |
| /register/reset/validate |
| /invite/accept |
Scenario Outline: Auth footer is always rendered, on mobile too
Given the viewport is <viewport>
When an auth screen with no footer slot content renders
Then the footer with the language chip and the theme chip is visible
And the chip is <chip_height> high
Examples:
| viewport | chip_height |
| 1440 x 900 | 28 px |
| 390 x 844 | 44 px |
Scenario: Chip reflects the current language
Given the stored language is fr
When /login renders
Then the chip reads "Français" and its aria-label is "Langue : Français"
Scenario Outline: Auth language popover
Given the user is signed out on /login in <locale_name>
When the user activates the language chip
Then the chip has aria-expanded "true" and --ui-bg-elevated
And a popover with role "menu" named "Language" opens above the chip, right-aligned and 36 px above its bottom edge, 168 px wide, --ui-bg, 1 px --ui-border, --rc-shadow-pop and 4 px padding
And it holds menuitemradio rows "English" (lang "en") and "Français" (lang "fr") that are 32 px high on desktop and 44 px on mobile
And the current language row has aria-checked "true" and a 16 px check icon at the right edge
Examples:
| locale_name |
| English |
| Français |
Scenario: Choosing a language in the auth popover closes it
Given the auth language popover is open on /login with English current
When the user chooses "Français"
Then the whole screen is French at once, including the card, the footer and the document title
And the document lang is "fr" and localStorage "renewcms-language" is "fr"
And the popover closes and focus returns to the language chip
And the chip reads "Français"
And the form values already typed, the route and the theme are unchanged
And no /v1 request occurs
Scenario: Dismiss the auth popover without changing language
Given the auth language popover is open
When the user presses Escape or clicks outside it
Then it closes, aria-expanded is "false" and the language is unchanged
And focus returns to the chip on Escape
Scenario: Auth popover keyboard
Given the language chip has focus
When the user presses Enter, Space or ArrowDown
Then the popover opens with focus on the current language row
And ArrowUp and ArrowDown move between the two rows and Enter or Space chooses one
Scenario: Language persists across public and signed-in screens
Given the user chose Français on /login
When they sign in, open the account menu and log out
Then Français is checked in the account menu and the /login chip reads "Français" again
And a storage failure still lets the choice apply for the current session, as in spa_internationalization
Scenario: Catalog parity for the new strings
Then the en and fr catalogs contain "Theme" and "Language: {0}"
And "Language: {0}" keeps its {0} parameter in French
And the strings "English" and "Français" are not translated
@@ -0,0 +1,35 @@
# Language switcher conformance QA
Accepted by the user on 2026-10-04 together with the slice specification. Decisions folded in: spa_internationalization picker scenarios are amended; theme and language rows are menuitemradio and the account menu and sheet stay open after a choice; the auth popover is specified from docs/design/components.md because the boards draw only the chip.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `/v1` per case and assert that a language change makes no `/v1` request. Reset the stored `renewcms-language` between tests. Do not freeze the system date.
1. UserMenu (desktop) and AccountMenuSheet (mobile): open the menu and assert the entry order Profile, separator, "Theme" label, System/Light/Dark, separator, "Language" label, English/Français, separator, Log out. Assert the label classes. LanguagePicker and any combobox must be absent.
2. Roles and ARIA: theme rows and language rows are menuitemradio with aria-checked on the current one and a check icon. Language rows carry lang "en"/"fr" and native names. With locale fr the names stay "English"/"Français" while "Theme"/"Language" become "Thème"/"Langue".
3. Account menu selection: choosing Français in each surface switches the visible copy, sets document lang and storage, keeps the menu open, and leaves route, theme and an unsaved display-name draft on /profile unchanged. Choosing English restores it. Choosing a theme keeps the mobile sheet open.
4. Account menu keyboard: arrow order and wrap across all entries, Enter/Space applies and keeps open, Escape and outside click close with no language change.
5. AuthShell: the footer is rendered with the language chip next to UColorModeSelect when belowSm is true and false, with no footer slot content, and slot content stays to the left. Assert aria-haspopup, aria-expanded, aria-label "Language: English" / "Langue : Français", the icon, the native name and the absence of a select.
6. Auth popover: opens via click, Enter, Space and ArrowDown; role menu named "Language", two menuitemradio rows with lang and checked state; selection applies the language, closes it and returns focus to the chip; Escape and outside click dismiss with no change. Typed form values on /login survive the switch.
7. Each auth screen (login, register, pending, verify, forgot-password, reset-password, accept-invite) mounts with the chip present.
8. Persistence: stored fr gives chip "Français"; the choice survives log out and re-render of /login; a localStorage write failure still switches for the session.
9. Catalog parity: en/fr keys include "Theme" and "Language: {0}" with matching parameters.
10. Update web/src/i18n/spa.spec.ts, web/src/i18n/i18n.spec.ts and any AuthShell, UserMenu or AccountMenuSheet spec asserting menuitemcheckbox or the old picker. Remove LanguagePicker and its tests if the component is removed.
11. Run task test:web and the type check; inspect the diff for introduced comments.
## Visual (`task visual:check -- <screen>`, hard gate at 10 %)
Desktop and mobile, light and dark, reusing the boards' sample data.
- profile, state "Account menu": board `deskMenu` and `mobMenu` with the Theme and Language labels, Light and English checked.
- Every auth screen and state (log-in, register, pending, verify, forgot-password, reset-password, accept-invite): all captures change because of the language chip (`hasFooter: true`), including mobile where the footer is new. Clock-sensitive states stay `skip: 'clock-sensitive'`.
- The boards draw no open auth language popover, so it has no visual gate. It is covered by the automated and manual checks.
## Manual (screenshots cannot cover)
- Keyboard focus order and visible focus ring in the account menu, the chip and the popover; focus return to the chip.
- Hover, press and motion on the rows and the chip.
- Popover placement above the chip and containment at 390 px width, including the 36 px offset next to the 44 px mobile chip. Touch targets at 44 px on mobile.
- French readability of "Thème", "Langue" and "Langue : Français" in the labels, chip and popover.
- Language retained after reload and logout.
+9 -13
View File
@@ -4,8 +4,7 @@ Feature: English and French throughout the SPA
Given all existing SPA interface text supports English and French Given all existing SPA interface text supports English and French
And English preserves existing copy and French provides equivalent meaning And English preserves existing copy and French provides equivalent meaning
And user content, API data and transactional emails remain unchanged And user content, API data and transactional emails remain unchanged
And visual checks are waived by the user for this slice And the language control follows features/language_switcher_conformance.feature
And the language control uses existing design tokens
Scenario Outline: Resolve the language before rendering Scenario Outline: Resolve the language before rendering
Given the browser-local stored language is <stored> Given the browser-local stored language is <stored>
@@ -21,12 +20,10 @@ Feature: English and French throughout the SPA
| unsupported | en | | unsupported | en |
| unreadable | en | | unreadable | en |
Scenario Outline: Open the language picker in the profile submenu Scenario Outline: Offer the language choice in the account menu
Given the user opens the <menu> Given the user opens the <menu>
When they activate the localized Language control Then a localized Language section offers English and Français as radio items
Then a compact scrollable picker offers English and Français
And the selected language is visibly and accessibly identified And the selected language is visibly and accessibly identified
And the picker uses Nuxt UI and existing design tokens
Examples: Examples:
| menu | | menu |
@@ -65,13 +62,12 @@ Feature: English and French throughout the SPA
And an unavailable French message falls back to English And an unavailable French message falls back to English
And user content, account details, filenames, URLs, API data and emails remain unchanged And user content, account details, filenames, URLs, API data and emails remain unchanged
Scenario Outline: Operate the picker accessibly Scenario Outline: Operate the language choice accessibly
Given the picker is open in the <menu> Given the <menu> is open
When the user navigates its options with the keyboard and confirms a choice When the user navigates the language items with the keyboard and confirms a choice
Then the language changes and the picker closes Then the language changes and the menu stays open
And focus returns to its language control When they press Escape or click outside
When they reopen it and press Escape or click outside Then the menu closes without changing language
Then the picker closes without changing language
Examples: Examples:
| menu | | menu |
+2 -4
View File
@@ -5,7 +5,7 @@ Accepted by the user on 2026-10-03 together with the slice specification.
## Automated ## Automated
1. Verify absent, English, French, unsupported and unreadable stored preferences; English defaults independently of browser language and document lang follows selection. 1. Verify absent, English, French, unsupported and unreadable stored preferences; English defaults independently of browser language and document lang follows selection.
2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized language entry and a compact bounded scrollable picker with English and Français, selected-state semantics, selection, dismissal and focus return. Preserve profile, theme and logout behavior. 2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized Language section with English and Français radio items, selected-state semantics, selection with the menu staying open, and dismissal. Preserve profile, theme and logout behavior. Details live in features/language_switcher_conformance.qa.md.
3. Switch both ways while editing an unsaved profile display name. Verify translated UI and unchanged draft, route, session and theme without additional API requests. 3. Switch both ways while editing an unsaved profile display name. Verify translated UI and unchanged draft, route, session and theme without additional API requests.
4. Verify browser-local persistence across reload initialization, navigation and logout/public authentication. Verify storage write failure allows switching for the current session. 4. Verify browser-local persistence across reload initialization, navigation and logout/public authentication. Verify storage write failure allows switching for the current session.
5. Preserve existing English tests and add French checks for login, validation, profile save failure, integration copy feedback, not-found, representative dialogs and notifications. 5. Preserve existing English tests and add French checks for login, validation, profile save failure, integration copy feedback, not-found, representative dialogs and notifications.
@@ -16,6 +16,4 @@ Accepted by the user on 2026-10-03 together with the slice specification.
## Visual and manual ## Visual and manual
Visual checks are SKIPPED by explicit user waiver for this slice because the designer is unavailable. The user authorized a coherent language control using existing design tokens. Do not edit design sources or claim pixel parity. The language control now has an official design. Its visual and manual checks live in features/language_switcher_conformance.qa.md. For the rest of this slice, confirm French text readability and language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
Manual checklist: desktop/mobile and light/dark coherence, viewport containment, constrained scrolling, French text readability, touch and mouse selection, keyboard movement and confirmation, Escape/outside dismissal, focus return and visible focus. Confirm language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
+1 -1
View File
@@ -254,7 +254,7 @@ Feature: Updates part 2: the Update button (download, verify, stage)
Scenario: Settings, owner, Ready to install Scenario: Settings, owner, Ready to install
Given the poll answers state "ready" Given the poll answers state "ready"
Then polling stops Then polling stops
And the block shows icon circle-check (success), "Version 2.3.0-beta is ready to install" / And the block shows icon clock (warning tone) with a "Pending" tag after the title, "Version 2.3.0-beta is ready to install" /
"The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." / "The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." /
"Nothing has changed yet." "Nothing has changed yet."
And the notes button is shown collapsed And the notes button is shown collapsed
+2 -1
View File
@@ -11,8 +11,9 @@ require (
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.28.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
+4 -2
View File
@@ -78,14 +78,16 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+32
View File
@@ -0,0 +1,32 @@
package article
import (
"errors"
"strings"
)
var ErrUnsupportedBodyFormat = errors.New("unsupported body format")
type BodyFormat string
const (
BodyFormatMarkdown BodyFormat = "markdown"
BodyFormatHTML BodyFormat = "html"
BodyFormatJSON BodyFormat = "json"
)
func ParseBodyFormat(value string) (BodyFormat, error) {
switch strings.ToLower(value) {
case "", string(BodyFormatMarkdown):
return BodyFormatMarkdown, nil
case string(BodyFormatHTML):
return BodyFormatHTML, nil
case string(BodyFormatJSON):
return BodyFormatJSON, nil
}
return "", ErrUnsupportedBodyFormat
}
type BodyRenderer interface {
Render(html string, format BodyFormat) (string, error)
}
+365
View File
@@ -0,0 +1,365 @@
package markdown
import (
"net/url"
"strconv"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
const (
markBold = "bold"
markItalic = "italic"
markLink = "link"
markCode = "code"
codeLanguagePrefix = "language-"
)
type mark struct {
kind string
href string
}
type inline struct {
text string
marks []mark
}
type blockKind int
const (
kindHeading blockKind = iota
kindParagraph
kindList
kindBlockquote
kindCode
kindRule
kindImage
)
type block struct {
kind blockKind
level int
inlines []inline
ordered bool
items [][]block
children []block
language string
text string
src string
alt string
align string
}
var transparentBlocks = map[string]bool{
"div": true, "section": true, "article": true, "header": true, "footer": true,
"main": true, "aside": true, "nav": true, "figure": true, "figcaption": true,
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true,
"td": true, "th": true, "dl": true, "dt": true, "dd": true, "details": true,
"summary": true, "li": true,
}
var skippedElements = map[string]bool{
"script": true, "style": true, "template": true, "head": true,
"noscript": true, "iframe": true, "object": true, "embed": true,
}
var alignments = map[string]bool{"center": true, "right": true, "justify": true}
var allowedLinkSchemes = map[string]bool{
"": true, "http": true, "https": true, "mailto": true, "tel": true,
}
func parseBlocks(source string) ([]block, error) {
context := &html.Node{Type: html.ElementNode, DataAtom: atom.Body, Data: "body"}
nodes, err := html.ParseFragment(strings.NewReader(source), context)
if err != nil {
return nil, err
}
collector := newCollector(false, paragraphBlock)
collector.walkAll(nodes, nil)
return collector.finish(false), nil
}
func paragraphBlock(inlines []inline) block {
return block{kind: kindParagraph, inlines: inlines}
}
func headingBlock(level int) func([]inline) block {
return func(inlines []inline) block {
return block{kind: kindHeading, level: level, inlines: inlines}
}
}
type collector struct {
out []block
current []inline
keepBlank bool
build func([]inline) block
}
func newCollector(keepBlank bool, build func([]inline) block) *collector {
return &collector{keepBlank: keepBlank, build: build}
}
func (c *collector) walkAll(nodes []*html.Node, marks []mark) {
for _, node := range nodes {
c.walk(node, marks)
}
}
func (c *collector) walkChildren(parent *html.Node, marks []mark) {
for child := parent.FirstChild; child != nil; child = child.NextSibling {
c.walk(child, marks)
}
}
func (c *collector) addText(text string, marks []mark) {
if text == "" {
return
}
c.current = appendInline(c.current, inline{text: text, marks: marks})
}
func (c *collector) flush() {
if len(c.current) == 0 {
return
}
if c.keepBlank || hasVisibleText(c.current) {
c.out = append(c.out, c.build(c.current))
}
c.current = nil
}
func (c *collector) finish(emitEmpty bool) []block {
hadBlocks := len(c.out) > 0
c.flush()
if emitEmpty && !hadBlocks && len(c.out) == 0 {
c.out = append(c.out, c.build(nil))
}
return c.out
}
func (c *collector) walk(node *html.Node, marks []mark) {
switch node.Type {
case html.TextNode:
c.addText(node.Data, marks)
return
case html.ElementNode:
default:
return
}
tag := node.Data
if skippedElements[tag] {
return
}
switch tag {
case "br":
c.addText("\n", marks)
case "strong", "b":
c.walkChildren(node, withMark(marks, mark{kind: markBold}))
case "em", "i":
c.walkChildren(node, withMark(marks, mark{kind: markItalic}))
case "code":
c.walkChildren(node, withMark(marks, mark{kind: markCode}))
case "a":
href := safeHref(attr(node, "href"))
if href == "" {
c.walkChildren(node, marks)
return
}
c.walkChildren(node, withMark(marks, mark{kind: markLink, href: href}))
case "img":
c.flush()
if src := attr(node, "src"); src != "" {
c.out = append(c.out, block{kind: kindImage, src: src, alt: attr(node, "alt")})
}
case "hr":
c.flush()
c.out = append(c.out, block{kind: kindRule})
case "p":
c.flush()
c.out = append(c.out, parseTextBlock(node, paragraphBlock)...)
case "h1", "h2", "h3", "h4", "h5", "h6":
c.flush()
level, _ := strconv.Atoi(tag[1:])
c.out = append(c.out, parseTextBlock(node, headingBlock(level))...)
case "ul", "ol":
c.flush()
c.out = append(c.out, parseList(node))
case "blockquote":
c.flush()
c.out = append(c.out, block{kind: kindBlockquote, children: parseContainer(node)})
case "pre":
c.flush()
c.out = append(c.out, parseCodeBlock(node))
default:
if transparentBlocks[tag] {
c.flush()
c.walkChildren(node, marks)
c.flush()
return
}
c.walkChildren(node, marks)
}
}
func parseTextBlock(node *html.Node, build func([]inline) block) []block {
inner := newCollector(true, build)
inner.walkChildren(node, nil)
blocks := inner.finish(true)
if align := alignFromStyle(attr(node, "style")); align != "" {
for index := range blocks {
if blocks[index].kind == kindParagraph || blocks[index].kind == kindHeading {
blocks[index].align = align
}
}
}
return blocks
}
func alignFromStyle(style string) string {
for _, declaration := range strings.Split(style, ";") {
name, value, found := strings.Cut(declaration, ":")
if !found || strings.ToLower(strings.TrimSpace(name)) != "text-align" {
continue
}
if align := strings.ToLower(strings.TrimSpace(value)); alignments[align] {
return align
}
}
return ""
}
func parseContainer(node *html.Node) []block {
inner := newCollector(false, paragraphBlock)
inner.walkChildren(node, nil)
return inner.finish(false)
}
func parseList(node *html.Node) block {
list := block{kind: kindList, ordered: node.Data == "ol"}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == "li" {
list.items = append(list.items, parseContainer(child))
}
}
return list
}
func parseCodeBlock(node *html.Node) block {
code := block{kind: kindCode, text: strings.TrimSuffix(textContent(node), "\n")}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == "code" {
code.language = languageFromClass(attr(child, "class"))
break
}
}
return code
}
func languageFromClass(class string) string {
for _, name := range strings.Fields(class) {
if strings.HasPrefix(name, codeLanguagePrefix) {
return strings.TrimPrefix(name, codeLanguagePrefix)
}
}
return ""
}
func textContent(node *html.Node) string {
var builder strings.Builder
var visit func(*html.Node)
visit = func(n *html.Node) {
switch {
case n.Type == html.TextNode:
builder.WriteString(n.Data)
case n.Type == html.ElementNode && skippedElements[n.Data]:
return
case n.Type == html.ElementNode && n.Data == "br":
builder.WriteString("\n")
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
visit(child)
}
}
visit(node)
return builder.String()
}
func attr(node *html.Node, name string) string {
for _, a := range node.Attr {
if a.Key == name {
return a.Val
}
}
return ""
}
func safeHref(href string) string {
trimmed := strings.TrimSpace(href)
parsed, err := url.Parse(trimmed)
if err != nil || !allowedLinkSchemes[strings.ToLower(parsed.Scheme)] {
return ""
}
return trimmed
}
func withMark(marks []mark, added mark) []mark {
for _, existing := range marks {
if existing.kind == added.kind {
return marks
}
}
next := make([]mark, 0, len(marks)+1)
next = append(next, marks...)
return append(next, added)
}
func hasVisibleText(inlines []inline) bool {
for _, item := range inlines {
if strings.TrimSpace(item.text) != "" {
return true
}
}
return false
}
func sameMarks(a, b []mark) bool {
if len(a) != len(b) {
return false
}
for index := range a {
if a[index] != b[index] {
return false
}
}
return true
}
func appendInline(inlines []inline, next inline) []inline {
if last := len(inlines) - 1; last >= 0 && sameMarks(inlines[last].marks, next.marks) {
inlines[last].text += next.text
return inlines
}
return append(inlines, next)
}
func withoutCodeMarks(inlines []inline) []inline {
merged := make([]inline, 0, len(inlines))
for _, item := range inlines {
kept := make([]mark, 0, len(item.marks))
for _, m := range item.marks {
if m.kind != markCode {
kept = append(kept, m)
}
}
merged = appendInline(merged, inline{text: item.text, marks: kept})
}
return merged
}
@@ -0,0 +1,33 @@
package markdown
import (
domain "RenewCMS/internal/domain/article"
)
type Converter struct{}
var _ domain.BodyRenderer = &Converter{}
func NewConverter() *Converter {
return &Converter{}
}
func (c *Converter) Render(html string, format domain.BodyFormat) (string, error) {
switch format {
case domain.BodyFormatHTML:
return html, nil
case domain.BodyFormatMarkdown:
blocks, err := parseBlocks(html)
if err != nil {
return "", err
}
return renderMarkdown(blocks), nil
case domain.BodyFormatJSON:
blocks, err := parseBlocks(html)
if err != nil {
return "", err
}
return renderJSON(blocks)
}
return "", domain.ErrUnsupportedBodyFormat
}
@@ -0,0 +1,111 @@
package markdown
import (
"bytes"
"encoding/json"
"strings"
)
type jsonMark struct {
Type string `json:"type"`
Href string `json:"href,omitempty"`
}
type jsonText struct {
Type string `json:"type"`
Text string `json:"text"`
Marks []jsonMark `json:"marks"`
}
type jsonHeading struct {
Type string `json:"type"`
Level int `json:"level"`
Align string `json:"align,omitempty"`
Children []jsonText `json:"children"`
}
type jsonParagraph struct {
Type string `json:"type"`
Align string `json:"align,omitempty"`
Children []jsonText `json:"children"`
}
type jsonList struct {
Type string `json:"type"`
Ordered bool `json:"ordered"`
Items [][]any `json:"items"`
}
type jsonBlockquote struct {
Type string `json:"type"`
Children []any `json:"children"`
}
type jsonCode struct {
Type string `json:"type"`
Language string `json:"language"`
Text string `json:"text"`
}
type jsonRule struct {
Type string `json:"type"`
}
type jsonImage struct {
Type string `json:"type"`
Src string `json:"src"`
Alt string `json:"alt"`
}
func renderJSON(blocks []block) (string, error) {
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(toJSONBlocks(blocks)); err != nil {
return "", err
}
return strings.TrimSuffix(buffer.String(), "\n"), nil
}
func toJSONBlocks(blocks []block) []any {
result := make([]any, 0, len(blocks))
for _, b := range blocks {
result = append(result, toJSONBlock(b))
}
return result
}
func toJSONBlock(b block) any {
switch b.kind {
case kindHeading:
return jsonHeading{Type: "heading", Level: b.level, Align: b.align, Children: toJSONTexts(b.inlines)}
case kindParagraph:
return jsonParagraph{Type: "paragraph", Align: b.align, Children: toJSONTexts(b.inlines)}
case kindList:
items := make([][]any, 0, len(b.items))
for _, item := range b.items {
items = append(items, toJSONBlocks(item))
}
return jsonList{Type: "list", Ordered: b.ordered, Items: items}
case kindBlockquote:
return jsonBlockquote{Type: "blockquote", Children: toJSONBlocks(b.children)}
case kindCode:
return jsonCode{Type: "code", Language: b.language, Text: b.text}
case kindImage:
return jsonImage{Type: "image", Src: b.src, Alt: b.alt}
}
return jsonRule{Type: "hr"}
}
func toJSONTexts(inlines []inline) []jsonText {
plain := withoutCodeMarks(inlines)
texts := make([]jsonText, 0, len(plain))
for _, item := range plain {
marks := make([]jsonMark, 0, len(item.marks))
for _, m := range item.marks {
marks = append(marks, jsonMark{Type: m.kind, Href: m.href})
}
texts = append(texts, jsonText{Type: "text", Text: item.text, Marks: marks})
}
return texts
}
@@ -0,0 +1,213 @@
package markdown
import (
"regexp"
"strconv"
"strings"
)
const (
blockSeparator = "\n\n"
hardBreak = "\\\n"
bulletMarker = "- "
codeFence = "```"
alignOpen = `<div style="text-align: `
alignOpenEnd = `">`
alignClose = "</div>"
)
var textEscaper = strings.NewReplacer(
"\\", "\\\\", "*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]", "<", "\\<",
)
var (
numberedLineStart = regexp.MustCompile(`^(\d+)([.)])(\s|$)`)
markerLineStart = regexp.MustCompile(`^(>|#{1,6}(\s|$)|[+-](\s|$|-)|=+\s*$)`)
)
var hrefEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
func renderMarkdown(blocks []block) string {
return renderBlocks(blocks, blockSeparator)
}
func renderBlocks(blocks []block, separator string) string {
parts := make([]string, 0, len(blocks))
for _, b := range blocks {
if rendered := renderBlock(b); rendered != "" {
parts = append(parts, rendered)
}
}
return strings.Join(parts, separator)
}
func renderBlock(b block) string {
rendered := renderPlainBlock(b)
if b.align == "" || rendered == "" {
return rendered
}
return alignOpen + b.align + alignOpenEnd + blockSeparator + rendered + blockSeparator + alignClose
}
func renderPlainBlock(b block) string {
switch b.kind {
case kindHeading:
return strings.Repeat("#", b.level) + " " + renderInlines(b.inlines)
case kindParagraph:
return escapeLineStarts(renderInlines(b.inlines))
case kindList:
return renderList(b)
case kindBlockquote:
return prefixLines(renderBlocks(b.children, blockSeparator), "> ", ">")
case kindCode:
return renderCode(b)
case kindImage:
return "![" + escapeAlt(b.alt) + "](" + hrefEscaper.Replace(b.src) + ")"
}
return "---"
}
func escapeLineStarts(text string) string {
lines := strings.Split(text, "\n")
for index, line := range lines {
line = numberedLineStart.ReplaceAllString(line, `$1\$2$3`)
if markerLineStart.MatchString(line) {
line = `\` + line
}
lines[index] = line
}
return strings.Join(lines, "\n")
}
func renderCode(b block) string {
fence := codeFence
for strings.Contains(b.text, fence) {
fence += "`"
}
return fence + b.language + "\n" + b.text + "\n" + fence
}
func escapeAlt(alt string) string {
return strings.NewReplacer("[", "\\[", "]", "\\]", "\n", " ").Replace(alt)
}
func renderList(b block) string {
lines := make([]string, 0, len(b.items))
for index, item := range b.items {
marker := bulletMarker
if b.ordered {
marker = strconv.Itoa(index+1) + ". "
}
content := renderBlocks(item, itemSeparator(item))
lines = append(lines, marker+indentContinuation(content, len(marker)))
}
return strings.Join(lines, "\n")
}
func itemSeparator(item []block) string {
textBlocks := 0
for _, b := range item {
if b.kind != kindList {
textBlocks++
}
}
if textBlocks > 1 {
return blockSeparator
}
return "\n"
}
func indentContinuation(content string, width int) string {
indent := strings.Repeat(" ", width)
lines := strings.Split(content, "\n")
for index := 1; index < len(lines); index++ {
if lines[index] != "" {
lines[index] = indent + lines[index]
}
}
return strings.Join(lines, "\n")
}
func prefixLines(content, prefix, emptyPrefix string) string {
lines := strings.Split(content, "\n")
for index, line := range lines {
if line == "" {
lines[index] = emptyPrefix
continue
}
lines[index] = prefix + line
}
return strings.Join(lines, "\n")
}
func renderInlines(inlines []inline) string {
var builder strings.Builder
for _, item := range inlines {
builder.WriteString(renderInline(item))
}
return builder.String()
}
func renderInline(item inline) string {
isCode := hasMarkKind(item.marks, markCode)
lines := strings.Split(item.text, "\n")
for index, line := range lines {
lines[index] = wrapMarks(item.marks, line, isCode)
}
return strings.Join(lines, hardBreak)
}
func hasMarkKind(marks []mark, kind string) bool {
for _, m := range marks {
if m.kind == kind {
return true
}
}
return false
}
func wrapMarks(marks []mark, text string, isCode bool) string {
core := strings.TrimSpace(text)
if core == "" {
return text
}
start := strings.Index(text, core)
leading, trailing := text[:start], text[start+len(core):]
var wrapped string
if isCode {
wrapped = inlineCode(core)
} else {
wrapped = escapeText(core)
}
for index := len(marks) - 1; index >= 0; index-- {
wrapped = applyMark(marks[index], wrapped)
}
return leading + wrapped + trailing
}
func inlineCode(text string) string {
fence := "`"
for strings.Contains(text, fence) {
fence += "`"
}
if strings.HasPrefix(text, "`") || strings.HasSuffix(text, "`") {
return fence + " " + text + " " + fence
}
return fence + text + fence
}
func applyMark(m mark, text string) string {
switch m.kind {
case markBold:
return "**" + text + "**"
case markItalic:
return "*" + text + "*"
case markLink:
return "[" + text + "](" + hrefEscaper.Replace(m.href) + ")"
}
return text
}
func escapeText(text string) string {
return textEscaper.Replace(text)
}
@@ -6,15 +6,17 @@ import (
type CreateArticleUseCase struct { type CreateArticleUseCase struct {
articleRepository article.Repository articleRepository article.Repository
bodyRenderer article.BodyRenderer
} }
type CreateArticleCommand struct { type CreateArticleCommand struct {
Title string Title string
Body string Body string
Format article.BodyFormat
} }
func NewCreateArticleUseCase(articleRepository article.Repository) *CreateArticleUseCase { func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository} return &CreateArticleUseCase{articleRepository, bodyRenderer}
} }
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) { func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
@@ -22,9 +24,13 @@ func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand)
return article.Article{}, err return article.Article{}, err
} }
return g.articleRepository.Create(article.Article{ created, err := g.articleRepository.Create(article.Article{
Title: createArticle.Title, Title: createArticle.Title,
Body: createArticle.Body, Body: createArticle.Body,
IsOnline: false, IsOnline: false,
}) })
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, created, createArticle.Format)
} }
+11 -6
View File
@@ -4,13 +4,14 @@ import "RenewCMS/internal/domain/article"
type GetArticleUseCase struct { type GetArticleUseCase struct {
articleRepository article.Repository articleRepository article.Repository
bodyRenderer article.BodyRenderer
} }
func NewGetArticleUseCase(articleRepository article.Repository) *GetArticleUseCase { func NewGetArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *GetArticleUseCase {
return &GetArticleUseCase{articleRepository} return &GetArticleUseCase{articleRepository, bodyRenderer}
} }
func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error) { func (u *GetArticleUseCase) GetOnlineArticle(id uint32, format article.BodyFormat) (article.Article, error) {
art, err := u.articleRepository.Get(id) art, err := u.articleRepository.Get(id)
if err != nil { if err != nil {
return article.Article{}, err return article.Article{}, err
@@ -20,11 +21,15 @@ func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error)
return article.Article{}, article.ErrArticleNotFound return article.Article{}, article.ErrArticleNotFound
} }
return art, nil return renderArticle(u.bodyRenderer, art, format)
} }
func (u *GetArticleUseCase) GetArticle(id uint32) (article.Article, error) { func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat) (article.Article, error) {
return u.articleRepository.Get(id) art, err := u.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
}
return renderArticle(u.bodyRenderer, art, format)
} }
func (g *GetArticleUseCase) GetArticleByName(name string) (article.Article, error) { func (g *GetArticleUseCase) GetArticleByName(name string) (article.Article, error) {
@@ -4,19 +4,28 @@ import "RenewCMS/internal/domain/article"
type ListArticlesUseCase struct { type ListArticlesUseCase struct {
articleRepository article.Repository articleRepository article.Repository
bodyRenderer article.BodyRenderer
} }
func NewListArticlesUseCase(articleRepository article.Repository) *ListArticlesUseCase { func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository} return &ListArticlesUseCase{articleRepository, bodyRenderer}
} }
func (u *ListArticlesUseCase) ListOnlineArticles() ([]article.Article, error) { func (u *ListArticlesUseCase) ListOnlineArticles(format article.BodyFormat) ([]article.Article, error) {
online := true online := true
return u.articleRepository.FindByFilters(article.Filters{ articles, err := u.articleRepository.FindByFilters(article.Filters{
IsOnline: &online, IsOnline: &online,
}) })
if err != nil {
return nil, err
}
return renderArticles(u.bodyRenderer, articles, format)
} }
func (u *ListArticlesUseCase) ListAllArticles() ([]article.Article, error) { func (u *ListArticlesUseCase) ListAllArticles(format article.BodyFormat) ([]article.Article, error) {
return u.articleRepository.FindByFilters(article.Filters{}) articles, err := u.articleRepository.FindByFilters(article.Filters{})
if err != nil {
return nil, err
}
return renderArticles(u.bodyRenderer, articles, format)
} }
@@ -4,16 +4,25 @@ import "RenewCMS/internal/domain/article"
type UpdateArticleUseCase struct { type UpdateArticleUseCase struct {
articleRepository article.Repository articleRepository article.Repository
bodyRenderer article.BodyRenderer
} }
func NewUpdateArticleUseCase(articleRepository article.Repository) *UpdateArticleUseCase { func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository} return &UpdateArticleUseCase{articleRepository, bodyRenderer}
} }
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Article, error) { func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string, format article.BodyFormat) (article.Article, error) {
return g.articleRepository.UpdateBody(id, body) updated, err := g.articleRepository.UpdateBody(id, body)
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, updated, format)
} }
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) { func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
return g.articleRepository.UpdateIsOnline(id, isOnline) updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, updated, format)
} }
@@ -0,0 +1,24 @@
package useCases
import "RenewCMS/internal/domain/article"
func renderArticle(renderer article.BodyRenderer, art article.Article, format article.BodyFormat) (article.Article, error) {
body, err := renderer.Render(art.Body, format)
if err != nil {
return article.Article{}, err
}
art.Body = body
return art, nil
}
func renderArticles(renderer article.BodyRenderer, articles []article.Article, format article.BodyFormat) ([]article.Article, error) {
rendered := make([]article.Article, 0, len(articles))
for _, art := range articles {
renderedArticle, err := renderArticle(renderer, art, format)
if err != nil {
return nil, err
}
rendered = append(rendered, renderedArticle)
}
return rendered, nil
}
+361
View File
@@ -0,0 +1,361 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
type fakeBodyRenderer struct {
fail bool
calls int
}
var _ domainArticle.BodyRenderer = &fakeBodyRenderer{}
func (f *fakeBodyRenderer) Render(html string, format domainArticle.BodyFormat) (string, error) {
f.calls++
if f.fail {
return "", errors.New("render failed")
}
return html, nil
}
const (
bodyFormatArticleOneHTML = "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
bodyFormatArticleOneMD = "## Plan\n\nReplace the **old** deck."
bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]`
)
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, renderer),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
})
})
})
return r
}
func seedBodyFormatRepo() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Body: bodyFormatArticleOneHTML, IsOnline: true}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Draft: Kitchen Remodel", Body: "<p>Draft</p>", IsOnline: false}
repo.articles[3] = domainArticle.Article{ID: 3, Title: "Second article", Body: "<p>Second</p>", IsOnline: true}
repo.nextID = 3
return repo
}
type bodyFormatResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat string `json:"body_format"`
IsOnline bool `json:"is_online"`
}
func serveBodyFormat(router http.Handler, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func decodeBodyFormatArticle(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) bodyFormatResponse {
t.Helper()
if rec.Code != expectedStatus {
t.Fatalf("expected status %d, got %d, body %q", expectedStatus, rec.Code, rec.Body.String())
}
var got bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
return got
}
func assertBodyFormat(t *testing.T, got bodyFormatResponse, format, expectedBody string) {
t.Helper()
if got.BodyFormat != format {
t.Errorf("expected body_format %q, got %q", format, got.BodyFormat)
}
if format == "json" {
assertSameJSON(t, string(got.Body), expectedBody)
return
}
var text string
if err := json.Unmarshal(got.Body, &text); err != nil {
t.Fatalf("body is not a JSON string: %s", got.Body)
}
if text != expectedBody {
t.Errorf("expected body %q, got %q", expectedBody, text)
}
}
func assertSameJSON(t *testing.T, actual, expected string) {
t.Helper()
var actualValue, expectedValue any
if err := json.Unmarshal([]byte(actual), &actualValue); err != nil {
t.Fatalf("actual is not JSON: %s", actual)
}
if err := json.Unmarshal([]byte(expected), &expectedValue); err != nil {
t.Fatalf("expected is not JSON: %s", expected)
}
actualBytes, _ := json.Marshal(actualValue)
expectedBytes, _ := json.Marshal(expectedValue)
if string(actualBytes) != string(expectedBytes) {
t.Errorf("expected %s, got %s", expectedBytes, actualBytes)
}
}
func TestArticleBodyFormatHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux {
return newBodyFormatRouter(repo, users, renderer, token)
}
realRouter := func() (*chi.Mux, *mockArticleRepo) {
repo := seedBodyFormatRepo()
return newRouter(repo, newPublicArticleReadUserRepo(true), markdown.NewConverter()), repo
}
verifiedCookie := mintPublicArticleReadCookie(t, token, 1)
t.Run("Single read negotiates the body format", func(t *testing.T) {
examples := []struct {
query, format, body string
}{
{"", "markdown", bodyFormatArticleOneMD},
{"?format=", "markdown", bodyFormatArticleOneMD},
{"?format=markdown", "markdown", bodyFormatArticleOneMD},
{"?format=MarkDown", "markdown", bodyFormatArticleOneMD},
{"?format=html", "html", bodyFormatArticleOneHTML},
{"?format=HTML", "html", bodyFormatArticleOneHTML},
{"?format=json", "json", bodyFormatArticleOneJSON},
{"?format=JSON", "json", bodyFormatArticleOneJSON},
}
for _, example := range examples {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1"+example.query, "", nil)
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
assertBodyFormat(t, got, example.format, example.body)
}
})
t.Run("List read negotiates every element", func(t *testing.T) {
router, _ := realRouter()
expectations := []struct {
query, format, secondBody string
}{
{"", "markdown", "Second"},
{"?format=html", "html", "<p>Second</p>"},
{"?format=json", "json", `[{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]`},
}
for _, expectation := range expectations {
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/"+expectation.query, "", nil)
var items []bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("failed to decode list: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 online articles, got %d", len(items))
}
for _, item := range items {
if item.BodyFormat != expectation.format {
t.Errorf("expected body_format %q, got %q", expectation.format, item.BodyFormat)
}
if item.ID == 3 {
assertBodyFormat(t, item, expectation.format, expectation.secondBody)
}
}
}
})
t.Run("An unsupported format is rejected", func(t *testing.T) {
for _, path := range []string{"/v1/article/1?format=pdf", "/v1/article/?format=xml", "/v1/article/9999?format=pdf", "/v1/article/1?format=md"} {
renderer := &fakeBodyRenderer{}
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), renderer)
rec := serveBodyFormat(router, http.MethodGet, path, "", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s: expected status 400, got %d", path, rec.Code)
}
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
t.Errorf("%s: unexpected body %q", path, rec.Body.String())
}
if renderer.calls != 0 {
t.Errorf("%s: renderer must not be called", path)
}
}
})
t.Run("An invalid id is reported before an invalid format", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/abc?format=pdf", "", nil)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
t.Run("The first repeated format parameter wins", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1?format=html&format=json", "", nil)
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
assertBodyFormat(t, got, "html", bodyFormatArticleOneHTML)
})
t.Run("Offline articles stay hidden whatever the format", func(t *testing.T) {
unverified := newRouter(seedBodyFormatRepo(), newMockUserRepo(), markdown.NewConverter())
rec := serveBodyFormat(unverified, http.MethodGet, "/v1/article/2?format=json", "", nil)
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
t.Run("Authenticated routes negotiate the response body", func(t *testing.T) {
paragraph := func(text string) string {
return `[{"type":"paragraph","children":[{"type":"text","text":"` + text + `","marks":[]}]}]`
}
type expectedBodies map[string]string
bodiesA := expectedBodies{"markdown": "A", "html": "<p>A</p>", "json": paragraph("A")}
bodiesB := expectedBodies{"markdown": "B", "html": "<p>B</p>", "json": paragraph("B")}
bodiesOne := expectedBodies{"markdown": bodyFormatArticleOneMD, "html": bodyFormatArticleOneHTML, "json": bodyFormatArticleOneJSON}
examples := []struct {
method, path, body string
status int
format string
bodies expectedBodies
}{
{http.MethodPost, "/v1/article/", `{"title":"Deck","body":"<p>A</p>"}`, 201, "markdown", bodiesA},
{http.MethodPost, "/v1/article/?format=html", `{"title":"Deck","body":"<p>A</p>"}`, 201, "html", bodiesA},
{http.MethodPost, "/v1/article/?format=json", `{"title":"Deck","body":"<p>A</p>"}`, 201, "json", bodiesA},
{http.MethodPatch, "/v1/article/1", `{"body":"<p>B</p>"}`, 200, "markdown", bodiesB},
{http.MethodPatch, "/v1/article/1?format=json", `{"body":"<p>B</p>"}`, 200, "json", bodiesB},
{http.MethodPatch, "/v1/article/1/publish", "", 200, "markdown", bodiesOne},
{http.MethodPatch, "/v1/article/1/unpublish?format=html", "", 200, "html", bodiesOne},
}
for _, example := range examples {
router, repo := realRouter()
rec := serveBodyFormat(router, example.method, example.path, example.body, verifiedCookie)
got := decodeBodyFormatArticle(t, rec, example.status)
assertBodyFormat(t, got, example.format, example.bodies[example.format])
stored := repo.articles[got.ID].Body
if example.bodies["html"] != stored {
t.Errorf("%s %s: stored body must stay HTML %q, got %q", example.method, example.path, example.bodies["html"], stored)
}
}
})
t.Run("An unsupported format on a write route changes nothing", func(t *testing.T) {
requests := []struct {
method, path, body string
}{
{http.MethodPatch, "/v1/article/1?format=pdf", `{"body":"<p>B</p>"}`},
{http.MethodPost, "/v1/article/?format=pdf", `{"title":"Deck","body":"<p>A</p>"}`},
{http.MethodPatch, "/v1/article/1/publish?format=pdf", ""},
{http.MethodPatch, "/v1/article/1/unpublish?format=pdf", ""},
}
for _, request := range requests {
repo := seedBodyFormatRepo()
renderer := &fakeBodyRenderer{}
router := newRouter(repo, newPublicArticleReadUserRepo(true), renderer)
rec := serveBodyFormat(router, request.method, request.path, request.body, verifiedCookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s %s: expected 400, got %d", request.method, request.path, rec.Code)
}
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
t.Errorf("%s %s: unexpected body %q", request.method, request.path, rec.Body.String())
}
if renderer.calls != 0 {
t.Errorf("%s %s: renderer must not be called", request.method, request.path)
}
if repo.articles[1].Body != bodyFormatArticleOneHTML {
t.Errorf("%s %s: stored body changed to %q", request.method, request.path, repo.articles[1].Body)
}
if !repo.articles[1].IsOnline {
t.Errorf("%s %s: is_online changed", request.method, request.path)
}
if repo.createCalled {
t.Errorf("%s %s: no article may be created", request.method, request.path)
}
}
})
t.Run("The authenticated list includes offline articles in the negotiated format", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/?format=json", "", verifiedCookie)
var items []bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("failed to decode list: %v", err)
}
seen := map[uint32]bool{}
for _, item := range items {
seen[item.ID] = true
if item.BodyFormat != "json" {
t.Errorf("expected json, got %q", item.BodyFormat)
}
}
if !seen[1] || !seen[2] {
t.Errorf("expected articles 1 and 2, got %v", seen)
}
})
t.Run("A converter failure answers 500", func(t *testing.T) {
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), &fakeBodyRenderer{fail: true})
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1", "", nil)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
}
func TestParseBodyFormat(t *testing.T) {
valid := map[string]domainArticle.BodyFormat{
"": domainArticle.BodyFormatMarkdown, "markdown": domainArticle.BodyFormatMarkdown, "MARKDOWN": domainArticle.BodyFormatMarkdown,
"html": domainArticle.BodyFormatHTML, "Html": domainArticle.BodyFormatHTML, "json": domainArticle.BodyFormatJSON,
}
for input, expected := range valid {
got, err := domainArticle.ParseBodyFormat(input)
if err != nil || got != expected {
t.Errorf("ParseBodyFormat(%q) = %q, %v", input, got, err)
}
}
for _, input := range []string{"pdf", "md", " html"} {
if _, err := domainArticle.ParseBodyFormat(input); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
t.Errorf("ParseBodyFormat(%q) should be unsupported, got %v", input, err)
}
}
}
+4 -4
View File
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+4 -4
View File
@@ -148,7 +148,7 @@ var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) { func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Valid Title", func(t *testing.T) { t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"} cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
@@ -174,17 +174,17 @@ func TestGetOnlineArticle(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, IsOnline: true} repo.articles[1] = article.Article{ID: 1, IsOnline: true}
repo.articles[2] = article.Article{ID: 2, IsOnline: false} repo.articles[2] = article.Article{ID: 2, IsOnline: false}
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Article Online", func(t *testing.T) { t.Run("Article Online", func(t *testing.T) {
_, err := uc.GetOnlineArticle(1) _, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Errorf("Expected article to be found") t.Errorf("Expected article to be found")
} }
}) })
t.Run("Article Offline", func(t *testing.T) { t.Run("Article Offline", func(t *testing.T) {
_, err := uc.GetOnlineArticle(2) _, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("Offline article should return not found") t.Errorf("Offline article should return not found")
} }
+5 -5
View File
@@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo), useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(repo), useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo), useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(repo), useCases.NewDeleteArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo), useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) { t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
title := strings.Repeat("\U0001F600", tc.runeCount) title := strings.Repeat("\U0001F600", tc.runeCount)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"}) result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
+4 -4
View File
@@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+78
View File
@@ -0,0 +1,78 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"encoding/json"
"reflect"
"strings"
"testing"
)
const hostileBody = `<h1>Title</h1><p>Intro <strong>bold</strong><script>alert(1)</script></p><style>p{}</style><iframe src="x"></iframe><ul><li><p>one</p><ul><li><p>deep</p></li></ul></li></ul><div><span style="color:red">kept</span></div><img src="/i.png" alt="I"><pre><code>a<script>z</script></code></pre>`
func TestConverterNeverLeaksRawHtml(t *testing.T) {
converter := markdown.NewConverter()
for _, format := range []domainArticle.BodyFormat{domainArticle.BodyFormatMarkdown, domainArticle.BodyFormatJSON} {
got, err := converter.Render(hostileBody, format)
if err != nil {
t.Fatalf("%s: unexpected error: %v", format, err)
}
for _, forbidden := range []string{"<script", "<style", "<iframe", "<span", "alert(1)", "p{}"} {
if strings.Contains(got, forbidden) {
t.Errorf("%s output leaks %q: %s", format, forbidden, got)
}
}
}
}
func TestJsonConverterCombinedDocument(t *testing.T) {
converter := markdown.NewConverter()
got, err := converter.Render(hostileBody, domainArticle.BodyFormatJSON)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var blocks []map[string]any
if err := json.Unmarshal([]byte(got), &blocks); err != nil {
t.Fatalf("body is not a JSON array: %v", err)
}
var types []string
for _, b := range blocks {
types = append(types, b["type"].(string))
}
expected := []string{"heading", "paragraph", "list", "paragraph", "image", "code"}
if !reflect.DeepEqual(types, expected) {
t.Errorf("expected block types %v, got %v", expected, types)
}
}
func TestJsonConverterEmptyBodyIsArray(t *testing.T) {
converter := markdown.NewConverter()
for _, source := range []string{"", " ", "<script>x</script>"} {
got, err := converter.Render(source, domainArticle.BodyFormatJSON)
if err != nil || got != "[]" {
t.Errorf("%q: expected [] got %q, %v", source, got, err)
}
}
}
func TestMarkdownConverterCodeBlockText(t *testing.T) {
converter := markdown.NewConverter()
examples := []struct{ name, html, markdown string }{
{"text node kept", "<pre><code>x</code></pre>", "```\nx\n```"},
{"skipped element dropped", "<pre><code>a<script>z</script>b</code></pre>", "```\nab\n```"},
{"style dropped", "<pre><code>a<style>z</style></code></pre>", "```\na\n```"},
{"break becomes newline", "<pre><code>a<br>b</code></pre>", "```\na\nb\n```"},
{"nested element text kept", "<pre><code>a<span>b</span>c</code></pre>", "```\nabc\n```"},
{"trailing newline trimmed", "<pre><code>a\n</code></pre>", "```\na\n```"},
{"fence grows around backticks", "<pre><code>```\nx</code></pre>", "````\n```\nx\n````"},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown)
if err != nil || got != example.markdown {
t.Errorf("expected %q, got %q, %v", example.markdown, got, err)
}
})
}
}
+4 -4
View File
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+58
View File
@@ -0,0 +1,58 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"testing"
)
func TestJsonBlocksConverter(t *testing.T) {
converter := markdown.NewConverter()
paragraphA := `{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}`
listOfA := `[{"type":"list","ordered":false,"items":[[` + paragraphA + `]]}]`
examples := []struct {
name, html, json string
}{
{"h1", "<h1>T</h1>", `[{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}]`},
{"h6", "<h6>S</h6>", `[{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}]`},
{"paragraph", "<p>plain</p>", `[{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}]`},
{"empty paragraph", "<p></p>", `[{"type":"paragraph","children":[]}]`},
{"bold", "<p><strong>b</strong></p>", `[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}]`},
{"italic", "<p><em>i</em></p>", `[{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}]`},
{"link", `<p><a href="https://example.com/x">l</a></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}]`},
{"nested marks outermost first", `<p><a href="/a"><strong><em>x</em></strong></a></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}]`},
{"line break", "<p>a<br>b</p>", `[{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}]`},
{"bullet list", "<ul><li><p>a</p></li><li><p>b</p></li></ul>", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}]`},
{"ordered list", "<ol><li><p>a</p></li></ol>", `[{"type":"list","ordered":true,"items":[[` + paragraphA + `]]}]`},
{"bare list item", "<ul><li>a</li></ul>", listOfA},
{"nested list", "<ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul>", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `,{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"n","marks":[]}]}]]}]]}]`},
{"data-indent ignored", `<ul><li data-indent="2"><p>a</p></li></ul>`, listOfA},
{"blockquote", "<blockquote><p>q</p></blockquote>", `[{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}]`},
{"code with language", `<pre><code class="language-go">x := 1</code></pre>`, `[{"type":"code","language":"go","text":"x := 1"}]`},
{"code without language", "<pre><code>raw</code></pre>", `[{"type":"code","language":"","text":"raw"}]`},
{"rule", "<hr>", `[{"type":"hr"}]`},
{"image with alt", `<img src="/static/uploadedImages/a.png" alt="A">`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}]`},
{"image without alt", `<img src="/static/uploadedImages/a.png">`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":""}]`},
{"image splits paragraph", `<p>x<img src="/i.png" alt="I">y</p>`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}]`},
{"span stripped", `<p>x<span style="color:red">y</span></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}]`},
{"inline code degrades", "<p>use <code>go test</code></p>", `[{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}]`},
{"script and style removed", "<p>ok</p><script>alert(1)</script><style>p{}</style>", `[{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}]`},
{"empty html", "", `[]`},
{"centered paragraph", `<p style="text-align: center">a</p>`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}]`},
{"right aligned heading", `<h2 style="text-align:right">T</h2>`, `[{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}]`},
{"justified paragraph", `<p style="color:red; text-align: JUSTIFY">a</p>`, `[{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}]`},
{"left alignment omitted", `<p style="text-align: left">a</p>`, `[` + paragraphA + `]`},
{"alignment skips the image inside a paragraph", `<p style="text-align: center">x<img src="/i.png" alt="I">y</p>`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","align":"center","children":[{"type":"text","text":"y","marks":[]}]}]`},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatJSON)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != example.json {
t.Errorf("expected %s, got %s", example.json, got)
}
})
}
}
+89
View File
@@ -0,0 +1,89 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"errors"
"testing"
)
func TestMarkdownConverterFidelity(t *testing.T) {
converter := markdown.NewConverter()
examples := []struct {
name, html, markdown string
}{
{"headings", "<h1>T</h1><h3>S</h3>", "# T\n\n### S"},
{"bold and italic", "<p><strong>b</strong> <em>i</em></p>", "**b** *i*"},
{"link", `<p><a href="https://example.com/x">link</a></p>`, "[link](https://example.com/x)"},
{"bullet list", "<ul><li><p>a</p></li><li><p>b</p></li></ul>", "- a\n- b"},
{"ordered list", "<ol><li><p>a</p></li><li><p>b</p></li></ol>", "1. a\n2. b"},
{"data-indent dropped", `<ul><li data-indent="2"><p>a</p></li></ul>`, "- a"},
{"nested list", "<ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul>", "- a\n - n"},
{"blockquote", "<blockquote><p>q</p></blockquote>", "> q"},
{"code block", `<pre><code class="language-go">x := 1</code></pre>`, "```go\nx := 1\n```"},
{"rule", "<hr>", "---"},
{"image", `<img src="/static/uploadedImages/a.png" alt="A">`, "![A](/static/uploadedImages/a.png)"},
{"script and style removed", "<p>ok</p><script>alert(1)</script><style>p{}</style>", "ok"},
{"unsupported span stripped", `<p>x<span style="color:red">y</span></p>`, "xy"},
{"inline code", "<p>use <code>go test</code></p>", "use `go test`"},
{"empty body", "", ""},
{"line break", "<p>a<br>b</p>", "a\\\nb"},
{"literal angle bracket is escaped", "<p>&lt;script&gt;</p>", "\\<script>"},
{"javascript link dropped", `<p><a href="javascript:alert(1)">x</a></p>`, "x"},
{"bold keeps outer spaces", "<p>a<strong> b </strong>c</p>", "a **b** c"},
{"loose list item", "<ul><li><p>a</p><p>b</p></li></ul>", "- a\n\n b"},
{"inline code with edge backtick", "<p><code>`x</code></p>", "`` `x ``"},
{"code block keeps br and drops script", "<pre><code>a<br>b<script>z</script></code></pre>", "```\na\nb\n```"},
{"adjacent same marks merge", "<p><strong>a</strong><strong>b</strong></p>", "**ab**"},
{"adjacent different marks stay apart", "<p><strong>a</strong><em>b</em></p>", "**a***b*"},
{"two paragraphs", "<p>a</p><p>b</p>", "a\n\nb"},
{"centered paragraph", `<p style="text-align: center">hi <strong>b</strong></p>`, "<div style=\"text-align: center\">\n\nhi **b**\n\n</div>"},
{"right aligned heading", `<h2 style="text-align:right">T</h2>`, "<div style=\"text-align: right\">\n\n## T\n\n</div>"},
{"justified among other styles", `<p style="color:red; TEXT-ALIGN: Justify">a</p>`, "<div style=\"text-align: justify\">\n\na\n\n</div>"},
{"tiptap style with trailing semicolon", `<p style="text-align: center;">a</p>`, "<div style=\"text-align: center\">\n\na\n\n</div>"},
{"tiptap explicit left", `<p style="text-align: left;">a</p>`, "a"},
{"left alignment is the default", `<p style="text-align: left">a</p>`, "a"},
{"unknown alignment ignored", `<p style="text-align: start">a</p>`, "a"},
{"style without value ignored", `<p style="text-align">a</p>`, "a"},
{"empty aligned paragraph", `<p style="text-align: center"></p>`, ""},
{"aligned paragraph in list item", `<ul><li><p style="text-align: center">a</p></li></ul>`, "- <div style=\"text-align: center\">\n\n a\n\n </div>"},
{"aligned paragraph in blockquote", `<blockquote><p style="text-align: center">q</p></blockquote>`, "> <div style=\"text-align: center\">\n>\n> q\n>\n> </div>"},
{"alignment skips the image inside a paragraph", `<p style="text-align: center">x<img src="/i.png" alt="I">y</p>`, "<div style=\"text-align: center\">\n\nx\n\n</div>\n\n![I](/i.png)\n\n<div style=\"text-align: center\">\n\ny\n\n</div>"},
{"text characters are escaped", "<p>a * b _c_ `d` [e]</p>", "a \\* b \\_c\\_ \\`d\\` \\[e\\]"},
{"leading hash is escaped", "<p># not a heading</p>", "\\# not a heading"},
{"hash without space is kept", "<p>#tag</p>", "#tag"},
{"leading dash is escaped", "<p>- not a list</p>", "\\- not a list"},
{"leading plus is escaped", "<p>+ not a list</p>", "\\+ not a list"},
{"leading quote marker is escaped", "<p>&gt; not a quote</p>", "\\> not a quote"},
{"leading number is escaped", "<p>1. not a list</p>", "1\\. not a list"},
{"leading number with parenthesis is escaped", "<p>2) not a list</p>", "2\\) not a list"},
{"leading rule is escaped", "<p>---</p>", "\\---"},
{"leading equals is escaped", "<p>==</p>", "\\=="},
{"inner dash is kept", "<p>a - b</p>", "a - b"},
{"marker after a line break is escaped", "<p>a<br>- b</p>", "a\\\n\\- b"},
{"combined document", `<h1>Title</h1><p style="text-align: center">Intro <strong>bold</strong> and <em>it</em></p><ul><li><p>one</p></li><li><p>two</p></li></ul><img src="/static/uploadedImages/a.png" alt="A"><pre><code class="language-go">x := 1</code></pre><blockquote><p>quote</p></blockquote><hr>`, "# Title\n\n<div style=\"text-align: center\">\n\nIntro **bold** and *it*\n\n</div>\n\n- one\n- two\n\n![A](/static/uploadedImages/a.png)\n\n```go\nx := 1\n```\n\n> quote\n\n---"},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != example.markdown {
t.Errorf("expected %q, got %q", example.markdown, got)
}
})
}
}
func TestMarkdownConverterHtmlAndUnknownFormat(t *testing.T) {
converter := markdown.NewConverter()
html := "<p>a</p><script>x</script>"
got, err := converter.Render(html, domainArticle.BodyFormatHTML)
if err != nil || got != html {
t.Errorf("html must pass through unchanged, got %q, %v", got, err)
}
if _, err := converter.Render(html, domainArticle.BodyFormat("pdf")); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
t.Errorf("expected ErrUnsupportedBodyFormat, got %v", err)
}
}
+4 -4
View File
@@ -52,10 +52,10 @@ func newMediaEnv(t *testing.T) *mediaEnv {
container := &api.UseCases{ container := &api.UseCases{
Token: env.token, Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles), CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
+4 -4
View File
@@ -126,11 +126,11 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
+4 -4
View File
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+4 -4
View File
@@ -24,11 +24,11 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+4 -4
View File
@@ -132,10 +132,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
return &api.UseCases{ return &api.UseCases{
Token: token, Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
+4 -4
View File
@@ -23,11 +23,11 @@ import (
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux { func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
+30 -30
View File
@@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) { t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
title := strings.Repeat("a", tc.length) title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"}) result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
@@ -62,7 +62,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) { func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.fail.failNext("Create") repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo) uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"}) _, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
@@ -84,8 +84,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_1", func(t *testing.T) { t.Run("GetArticle_1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(1) result, err := uc.GetArticle(1, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -96,8 +96,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_2", func(t *testing.T) { t.Run("GetArticle_2", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(2) result, err := uc.GetArticle(2, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -108,8 +108,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_9", func(t *testing.T) { t.Run("GetArticle_9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticle(9) _, err := uc.GetArticle(9, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
@@ -117,8 +117,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_1", func(t *testing.T) { t.Run("GetOnlineArticle_1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetOnlineArticle(1) result, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -129,8 +129,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_2", func(t *testing.T) { t.Run("GetOnlineArticle_2", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(2) _, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
@@ -138,8 +138,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_9", func(t *testing.T) { t.Run("GetOnlineArticle_9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(9) _, err := uc.GetOnlineArticle(9, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
@@ -155,7 +155,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
t.Run("Backyard_Renovation_Update", func(t *testing.T) { t.Run("Backyard_Renovation_Update", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticleByName("Backyard Renovation Update") result, err := uc.GetArticleByName("Backyard Renovation Update")
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -167,7 +167,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
t.Run("Unknown_Title", func(t *testing.T) { t.Run("Unknown_Title", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo) uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticleByName("Unknown Title") _, err := uc.GetArticleByName("Unknown Title")
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -198,8 +198,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListOnlineArticles", func(t *testing.T) { t.Run("ListOnlineArticles", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles() result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -208,8 +208,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListAllArticles", func(t *testing.T) { t.Run("ListAllArticles", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListAllArticles() result, err := uc.ListAllArticles(article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -219,9 +219,9 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) { func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles() result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -242,8 +242,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) { t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateBody(1, "New body") result, err := uc.UpdateBody(1, "New body", article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -254,8 +254,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) { t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateBody(9, "New body") _, err := uc.UpdateBody(9, "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
@@ -263,8 +263,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) { t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateIsOnline(1, true) result, err := uc.UpdateIsOnline(1, true, article.BodyFormatHTML)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -275,8 +275,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) { t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo) uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateIsOnline(9, true) _, err := uc.UpdateIsOnline(9, true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
+4 -4
View File
@@ -21,11 +21,11 @@ import (
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+4 -4
View File
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase, getUserUseCase,
) )
+3
View File
@@ -10,6 +10,7 @@
"@tiptap/core": "3.31.3", "@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3", "@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3", "@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3", "@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3", "@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0", "@vueuse/core": "14.4.0",
@@ -425,6 +426,8 @@
"@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="], "@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="],
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="], "@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="],
"@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="], "@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="],
+1
View File
@@ -21,6 +21,7 @@
"@tiptap/core": "3.31.3", "@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3", "@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3", "@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3", "@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3", "@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0", "@vueuse/core": "14.4.0",
+38 -11
View File
@@ -1,13 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { locale, t } from '@/i18n'
import { ref } from 'vue' import { ref, useId } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode' import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu' import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import LanguagePicker from '@/components/LanguagePicker.vue' import { chooseLanguage, languageOptions } from '@/composables/useLanguage'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="combobox"]' const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted' const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] font-normal text-highlighted'
const SECTION_LABEL_CLASS =
'px-4 pt-2 pb-0.5 text-[0.6875rem] font-medium uppercase tracking-[0.04em] text-muted'
const props = defineProps<{ const props = defineProps<{
username: string username: string
@@ -29,6 +31,8 @@ const {
goToProfile: navigateToProfile, goToProfile: navigateToProfile,
} = useAccountMenu(() => props) } = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null) const menu = ref<HTMLElement | null>(null)
const themeLabelId = useId()
const languageLabelId = useId()
function goToProfile() { function goToProfile() {
open.value = false open.value = false
@@ -37,7 +41,6 @@ function goToProfile() {
function chooseColorMode(value: ColorModePreference) { function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value colorMode.preference = value
open.value = false
} }
function logOut() { function logOut() {
@@ -46,7 +49,6 @@ function logOut() {
} }
function moveFocus(event: KeyboardEvent) { function moveFocus(event: KeyboardEvent) {
if ((event.target as HTMLElement).closest('[role="combobox"]')) return
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? []) const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) { if (items.length === 0) {
return return
@@ -96,7 +98,7 @@ function moveFocus(event: KeyboardEvent) {
:text="initials" :text="initials"
:ui="{ :ui="{
root: 'size-9 bg-secondary-300', root: 'size-9 bg-secondary-300',
fallback: 'text-[0.8125rem] font-semibold text-primary-900', fallback: 'text-[0.8125rem] pt-0.5 font-semibold text-primary-900',
}" }"
/> />
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
@@ -120,14 +122,14 @@ function moveFocus(event: KeyboardEvent) {
{{ t('Profile') }} {{ t('Profile') }}
</UButton> </UButton>
<USeparator class="my-1" />
<LanguagePicker mobile />
<USeparator class="my-1" /> <USeparator class="my-1" />
<div role="group" :aria-labelledby="themeLabelId">
<div :id="themeLabelId" :class="SECTION_LABEL_CLASS">{{ t('Theme') }}</div>
<UButton <UButton
v-for="option in colorModeOptions" v-for="option in colorModeOptions"
:key="option.value" :key="option.value"
role="menuitemcheckbox" role="menuitemradio"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
block block
@@ -141,6 +143,31 @@ function moveFocus(event: KeyboardEvent) {
> >
{{ option.label }} {{ option.label }}
</UButton> </UButton>
</div>
<USeparator class="my-1" />
<div role="group" :aria-labelledby="languageLabelId">
<div :id="languageLabelId" :class="SECTION_LABEL_CLASS">
{{ t('Language') }}
</div>
<UButton
v-for="option in languageOptions"
:key="option.value"
role="menuitemradio"
color="neutral"
variant="ghost"
block
:lang="option.value"
:aria-checked="locale === option.value"
:trailing-icon="locale === option.value ? 'i-lucide-check' : undefined"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseLanguage(option.value)"
>
{{ option.label }}
</UButton>
</div>
<USeparator class="my-1" /> <USeparator class="my-1" />
+2
View File
@@ -5,6 +5,7 @@ import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui' import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent' import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleTextAlign } from '@/lib/articleAlign'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage' import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
const props = withDefaults( const props = withDefaults(
@@ -27,6 +28,7 @@ const emit = defineEmits<{
const editorInstance = ref<Editor | null>(null) const editorInstance = ref<Editor | null>(null)
const extensions = [ const extensions = [
ArticleIndent, ArticleIndent,
ArticleTextAlign,
ArticleImage.configure({ ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId), onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}), }),
+9 -4
View File
@@ -3,6 +3,7 @@ import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue' import AppLogo from '@/components/AppLogo.vue'
import LanguageChip from '@/components/LanguageChip.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation' import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
const props = defineProps<{ const props = defineProps<{
@@ -61,12 +62,13 @@ const footerClass = computed(() => [
cardClass.value, cardClass.value,
]) ])
const colorModeSelectUi = { const colorModeSelectUi = computed(() => ({
base: 'h-7 ps-7 pe-2 text-sm text-toned', base: belowSm.value ? 'h-11 ps-7 pe-2 text-sm text-toned' : 'h-7 ps-7 pe-2 text-sm text-toned',
leading: 'ps-2', leading: 'ps-2',
leadingIcon: 'size-3.5 text-toned', leadingIcon: 'size-3.5 text-toned',
trailing: 'hidden', trailing: 'hidden',
} content: 'w-auto min-w-36',
}))
watch( watch(
() => props.title, () => props.title,
@@ -118,11 +120,14 @@ watch(
/> />
</UPageCard> </UPageCard>
<footer v-if="!belowSm || $slots.footer" :class="footerClass"> <footer :class="footerClass">
<span class="flex flex-wrap items-center gap-1.5"> <span class="flex flex-wrap items-center gap-1.5">
<slot name="footer" /> <slot name="footer" />
</span> </span>
<span class="flex items-center gap-2">
<LanguageChip :mobile="belowSm" />
<UColorModeSelect size="sm" :ui="colorModeSelectUi" /> <UColorModeSelect size="sm" :ui="colorModeSelectUi" />
</span>
</footer> </footer>
</main> </main>
</template> </template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { locale, t } from '@/i18n'
import { chooseLanguage, languageName, languageOptions } from '@/composables/useLanguage'
import { LANGUAGE_MENU_CLASS, useLanguageMenuRadios } from '@/composables/useRadioMenuRoles'
const props = defineProps<{ mobile?: boolean }>()
const open = ref(false)
useLanguageMenuRadios(open)
const items = computed(() =>
languageOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: locale.value === option.value,
class: props.mobile
? 'h-11 items-center py-0 text-base font-normal text-highlighted'
: 'h-8 items-center py-0 text-base font-normal text-highlighted',
onUpdateChecked: () => chooseLanguage(option.value),
})),
)
const chipClass = computed(() => [
props.mobile ? 'h-11' : 'h-7',
'gap-1.5 rounded-none border border-accented bg-transparent px-2 text-sm font-normal text-toned ring-0 hover:bg-elevated',
open.value ? 'bg-elevated' : '',
])
function focusCurrentRow() {
document.querySelector<HTMLElement>(`.${LANGUAGE_MENU_CLASS} [aria-checked="true"]`)?.focus()
}
watch(open, (value) => {
if (value) setTimeout(focusCurrentRow, 0)
})
</script>
<template>
<UDropdownMenu
v-model:open="open"
:items="items"
:content="{
side: 'top',
align: 'end',
sideOffset: 8,
loop: true,
}"
:ui="{ content: `w-42 p-1 ${LANGUAGE_MENU_CLASS}`, itemTrailingIcon: 'size-4' }"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-languages"
:aria-label="t('Language: {0}', [languageName])"
:aria-expanded="open"
:class="chipClass"
:ui="{ leadingIcon: 'size-3.5 text-toned' }"
>
{{ languageName }}
</UButton>
</UDropdownMenu>
</template>
-44
View File
@@ -1,44 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { locale, setLocale, t, type Locale } from '@/i18n'
defineProps<{ mobile?: boolean }>()
const languages = [
{ label: 'English', value: 'en' },
{ label: 'Français', value: 'fr' },
]
const selected = computed({
get: () => locale.value,
set: (value: string) => setLocale(value as Locale),
})
</script>
<template>
<div
:class="mobile ? 'flex items-center gap-3 px-4 py-2' : 'flex items-center gap-2 px-2 py-1'"
>
<UIcon name="i-lucide-languages" class="size-4 shrink-0 text-toned" />
<USelect
v-model="selected"
:items="languages"
:aria-label="t('Language')"
:content="{
side: mobile ? 'top' : 'right',
align: 'start',
collisionPadding: 16,
}"
:ui="{
content: 'w-40',
viewport: 'max-h-40 overflow-y-auto',
base: 'min-w-0 flex-1',
}"
:class="mobile ? 'h-11' : 'h-8'"
>
<template #default>
<span class="truncate">{{ t('Language') }}</span>
</template>
</USelect>
</div>
</template>
+1 -1
View File
@@ -120,7 +120,7 @@ async function onSubmit() {
formAlert.value = '' formAlert.value = ''
submitting.value = true submitting.value = true
try { try {
const article = await api.request<Article>('/v1/article/', { const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }), body: JSON.stringify({ title: title.value, body: body.value }),
+12
View File
@@ -17,6 +17,7 @@ const props = defineProps<{
title: string title: string
text: string text: string
meta?: string meta?: string
tag?: string
live?: 'polite' | 'off' live?: 'polite' | 'off'
}>() }>()
@@ -37,10 +38,21 @@ const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
/> />
</span> </span>
<div class="flex min-w-0 flex-1 flex-col gap-1"> <div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-base font-semibold text-highlighted">{{ title }}</span> <span class="text-base font-semibold text-highlighted">{{ title }}</span>
<span
v-if="tag"
data-testid="update-tag"
class="inline-flex h-5 items-center border border-warning bg-default px-1.5 font-mono text-[0.6875rem] font-medium text-warning"
>{{ tag }}</span
>
</div>
<span class="text-sm text-pretty text-toned">{{ text }}</span> <span class="text-sm text-pretty text-toned">{{ text }}</span>
<slot /> <slot />
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span> <span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
<div v-if="$slots.action" class="flex pt-2">
<slot name="action" />
</div>
</div> </div>
</div> </div>
</template> </template>
+34 -4
View File
@@ -39,6 +39,7 @@ interface Block {
title: string title: string
text: string text: string
meta?: string meta?: string
tag?: string
} }
interface ActionButton { interface ActionButton {
@@ -187,8 +188,11 @@ const applyBlocks = computed<Record<ApplyView, Block>>(() => ({
}, },
}, },
ready: { ready: {
tone: 'success', tone: 'warning',
icon: 'i-lucide-circle-check', icon: 'i-lucide-clock',
get tag() {
return t('Pending')
},
get title() { get title() {
return t('Version {0} is ready to install', [newVersion.value]) return t('Version {0} is ready to install', [newVersion.value])
}, },
@@ -498,6 +502,12 @@ const actionButtons = computed<ActionButton[]>(() => {
}) })
}) })
const inBlockButton = computed(() =>
applyView.value === 'ready' ? (actionButtons.value[0] ?? null) : null,
)
const rowButtons = computed(() => (inBlockButton.value ? [] : actionButtons.value))
const showRestartHint = computed(() => actionButtons.value.includes(plainRestartButton)) const showRestartHint = computed(() => actionButtons.value.includes(plainRestartButton))
function withPlainRestart(button: ActionButton) { function withPlainRestart(button: ActionButton) {
@@ -577,6 +587,7 @@ const notes = computed(() => {
:title="block.title" :title="block.title"
:text="block.text" :text="block.text"
:meta="block.meta" :meta="block.meta"
:tag="block.tag"
:live="progressView ? 'off' : 'polite'" :live="progressView ? 'off' : 'polite'"
> >
<UpdateProgress <UpdateProgress
@@ -585,6 +596,25 @@ const notes = computed(() => {
:text="progressView.text" :text="progressView.text"
:value="progressView.value" :value="progressView.value"
/> />
<template v-if="inBlockButton" #action>
<UButton
:data-testid="inBlockButton.action === 'restart' ? 'restart-button' : undefined"
:color="inBlockButton.primary ? 'primary' : 'neutral'"
:variant="inBlockButton.primary ? 'solid' : 'outline'"
:icon="inBlockButton.icon"
:disabled="inBlockButton.disabled"
class="h-11 justify-center gap-1.5 px-3.5 md:h-8"
:class="
inBlockButton.primary
? ''
: 'border border-accented bg-default text-highlighted ring-0'
"
:ui="{ leadingIcon: 'size-4' }"
@click="onAction(inBlockButton)"
>
{{ inBlockButton.label }}
</UButton>
</template>
</UpdateStatusBlock> </UpdateStatusBlock>
<p v-if="canApplyView" role="status" data-testid="update-announcer" class="sr-only"> <p v-if="canApplyView" role="status" data-testid="update-announcer" class="sr-only">
@@ -595,9 +625,9 @@ const notes = computed(() => {
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" /> <ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
<div v-if="actionButtons.length" class="flex flex-col gap-2 md:flex-row md:items-center"> <div v-if="rowButtons.length" class="flex flex-col gap-2 md:flex-row md:items-center">
<UButton <UButton
v-for="button in actionButtons" v-for="button in rowButtons"
:key="button.label" :key="button.label"
:data-testid="button.action === 'restart' ? 'restart-button' : undefined" :data-testid="button.action === 'restart' ? 'restart-button' : undefined"
:color="button.primary ? 'primary' : 'neutral'" :color="button.primary ? 'primary' : 'neutral'"
+44 -36
View File
@@ -1,13 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { locale, t } from '@/i18n'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useEventListener, useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu' import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import LanguagePicker from '@/components/LanguagePicker.vue' import { chooseLanguage, languageOptions } from '@/composables/useLanguage'
import { useAccountMenuRadios } from '@/composables/useRadioMenuRoles'
const DESKTOP_QUERY = '(min-width: 64rem)' const DESKTOP_QUERY = '(min-width: 64rem)'
const ITEM_CLASS = 'h-8 items-center py-0' const ITEM_CLASS = 'h-8 items-center gap-2.5 px-2 py-0 text-sm font-normal text-highlighted'
const CHOICE_CLASS = 'h-8 items-center gap-2.5 px-2 py-0 text-base font-normal text-highlighted'
const SECTION_LABEL_CLASS =
'px-2 pt-1.5 pb-0.5 text-[0.6875rem] font-medium uppercase tracking-[0.04em] text-muted'
const props = defineProps<{ const props = defineProps<{
username: string username: string
@@ -25,31 +29,8 @@ const emit = defineEmits<{
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props) const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false) const open = ref(false)
useAccountMenuRadios(open)
const chip = ref<{ $el: HTMLElement } | null>(null) const chip = ref<{ $el: HTMLElement } | null>(null)
const languagePicker = ref<{ $el: HTMLElement } | null>(null)
function focusLanguage(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[role="menu"]') !== languagePicker.value?.$el.closest('[role="menu"]'))
return
if (target.closest('[role="combobox"]')) return
const entries = Array.from(
target
.closest('[role="menu"]')
?.querySelectorAll<HTMLElement>('[role="menuitem"], [role="menuitemcheckbox"]') ?? [],
)
const movesToLanguage =
event.key === 'End' ||
(event.key === 'ArrowDown' && target === entries[entries.length - 1]) ||
(event.key === 'ArrowUp' && target === entries[0])
if (movesToLanguage) {
event.preventDefault()
languagePicker.value?.$el.querySelector<HTMLElement>('[role="combobox"]')?.focus()
}
}
useEventListener(document, 'keydown', focusLanguage, { capture: true })
const chipActive = computed(() => open.value || onProfile.value) const chipActive = computed(() => open.value || onProfile.value)
@@ -94,11 +75,19 @@ const items = computed(() => [
onSelect: goToProfile, onSelect: goToProfile,
}, },
], ],
colorModeOptions.map((option) => ({ [
{
type: 'label' as const,
get label() {
return t('Theme')
},
class: SECTION_LABEL_CLASS,
},
...colorModeOptions.map((option) => ({
type: 'checkbox' as const, type: 'checkbox' as const,
label: option.label, label: option.label,
checked: colorMode.preference === option.value, checked: colorMode.preference === option.value,
class: ITEM_CLASS, class: CHOICE_CLASS,
onSelect: (event: Event) => event.preventDefault(), onSelect: (event: Event) => event.preventDefault(),
onUpdateChecked: (checked: boolean) => { onUpdateChecked: (checked: boolean) => {
if (checked) { if (checked) {
@@ -106,6 +95,24 @@ const items = computed(() => [
} }
}, },
})), })),
],
[
{
type: 'label' as const,
get label() {
return t('Language')
},
class: SECTION_LABEL_CLASS,
},
...languageOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
checked: locale.value === option.value,
class: CHOICE_CLASS,
onSelect: (event: Event) => event.preventDefault(),
onUpdateChecked: () => chooseLanguage(option.value),
})),
],
[ [
{ {
get label() { get label() {
@@ -123,8 +130,12 @@ const items = computed(() => [
<UDropdownMenu <UDropdownMenu
:open="open" :open="open"
:items="items" :items="items"
:content="{ side: 'top', align: 'start' }" :content="{ side: 'top', align: 'start', sideOffset: 4, loop: true }"
:ui="{ content: 'w-56 p-1' }" :ui="{
content: 'w-56 p-0 ring-0 border border-default',
itemLeadingIcon: 'size-4 text-highlighted',
itemTrailingIcon: 'size-4',
}"
@update:open="onUpdateOpen" @update:open="onUpdateOpen"
> >
<UButton <UButton
@@ -156,11 +167,8 @@ const items = computed(() => [
<template #account-header> <template #account-header>
<span class="flex min-w-0 flex-col gap-0.5"> <span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-highlighted">{{ name }}</span> <span class="truncate text-sm font-semibold text-highlighted">{{ name }}</span>
<span class="truncate text-xs font-normal text-muted">{{ email }}</span> <span class="truncate text-xs/5 font-normal text-muted">{{ email }}</span>
</span> </span>
</template> </template>
<template #content-bottom>
<LanguagePicker ref="languagePicker" />
</template>
</UDropdownMenu> </UDropdownMenu>
</template> </template>
@@ -56,6 +56,35 @@ afterEach(async () => {
await router.push('/login') await router.push('/login')
}) })
describe('the alignment toolbar buttons align the current block', () => {
const ALIGNMENTS = ['left', 'center', 'right', 'justify']
const FIRST_ALIGN_BUTTON = 7
function alignButton(name: string) {
const buttons = document.body.querySelectorAll<HTMLButtonElement>('.article-editor button')
return buttons[FIRST_ALIGN_BUTTON + ALIGNMENTS.indexOf(name)] ?? null
}
it.each(ALIGNMENTS)('keeps the %s button enabled', async (name) => {
await mountArticleEditor()
const button = alignButton(name)
expect(button).not.toBeNull()
expect(button?.disabled).toBe(false)
})
it('writes the alignment into the body when a button is clicked', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
alignButton('center')?.click()
await flushPromises()
expect(editor.props('modelValue')).toContain('text-align: center')
})
})
describe('insertImage stamps the inserted image with its numeric id', () => { describe('insertImage stamps the inserted image with its numeric id', () => {
it('renders data-image-id on the img and emits the updated body', async () => { it('renders data-image-id on the img and emits the updated body', async () => {
const wrapper = await mountArticleEditor() const wrapper = await mountArticleEditor()
@@ -296,10 +296,18 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA') expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(menu.textContent).toContain('marta@renew.example') expect(menu.textContent).toContain('marta@renew.example')
const labels = Array.from( const labels = Array.from(
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'), menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]'),
).map((item) => item.textContent?.trim()) ).map((item) => item.textContent?.trim())
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out']) expect(labels).toEqual([
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) => 'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]').forEach((item) =>
expect(item.className).toContain('h-12'), expect(item.className).toContain('h-12'),
) )
}) })
@@ -325,7 +333,7 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(document.body.querySelector('[role="menu"]')).toBeNull() expect(document.body.querySelector('[role="menu"]')).toBeNull()
}) })
it('applies a theme choice and closes the sheet', async () => { it('applies a theme choice and keeps the sheet open', async () => {
const colorMode = useColorMode() const colorMode = useColorMode()
colorMode.preference = 'system' colorMode.preference = 'system'
await mountShell('/home', { width: MOBILE_WIDTH }) await mountShell('/home', { width: MOBILE_WIDTH })
@@ -339,13 +347,13 @@ describe('Account menu: opened from the drawer on mobile', () => {
await flushPromises() await flushPromises()
const dark = Array.from( const dark = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitemcheckbox"]'), document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitemradio"]'),
).find((item) => item.textContent?.trim() === 'Dark')! ).find((item) => item.textContent?.trim() === 'Dark')!
dark.click() dark.click()
await flushPromises() await flushPromises()
expect(colorMode.preference).toBe('dark') expect(colorMode.preference).toBe('dark')
expect(document.body.querySelector('[role="menu"]')).toBeNull() expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
}) })
it('logs out from the sheet', async () => { it('logs out from the sheet', async () => {
@@ -206,7 +206,7 @@ describe('Submitting disables the form and cannot be dismissed', () => {
await clickButton('Create draft') await clickButton('Create draft')
expect(requestMock).toHaveBeenCalledWith( expect(requestMock).toHaveBeenCalledWith(
'/v1/article/', '/v1/article/?format=html',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
body: JSON.stringify({ title: TITLE, body: BODY }), body: JSON.stringify({ title: TITLE, body: BODY }),
@@ -336,7 +336,7 @@ describe('Retrying after an Error succeeds', () => {
expect(requestMock).toHaveBeenCalledTimes(2) expect(requestMock).toHaveBeenCalledTimes(2)
expect(requestMock).toHaveBeenNthCalledWith( expect(requestMock).toHaveBeenNthCalledWith(
2, 2,
'/v1/article/', '/v1/article/?format=html',
expect.objectContaining({ expect.objectContaining({
body: JSON.stringify({ title: TITLE, body: BODY }), body: JSON.stringify({ title: TITLE, body: BODY }),
}), }),
+21 -5
View File
@@ -51,7 +51,7 @@ function menu() {
function itemsIn(container: ParentNode) { function itemsIn(container: ParentNode) {
return Array.from( return Array.from(
container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'), container.querySelectorAll('[role="menuitem"], [role="menuitemradio"]'),
) as HTMLElement[] ) as HTMLElement[]
} }
@@ -98,18 +98,26 @@ describe('The chip opens the account menu instead of logging out', () => {
}) })
describe('Account menu: opened on desktop', () => { describe('Account menu: opened on desktop', () => {
it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => { it('lists the header, Profile, the theme and language choices and Log out with a divider between its groups', async () => {
await mountMenu() await mountMenu()
await openMenu() await openMenu()
const header = menu()!.querySelector('[role="group"]')! const header = menu()!.querySelector('[role="group"]')!
expect(header.textContent).toContain('marta') expect(header.textContent).toContain('marta')
expect(header.textContent).toContain('marta@renew.example') expect(header.textContent).toContain('marta@renew.example')
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out']) expect(itemLabels()).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
expect( expect(
menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'), menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
).toBe(true) ).toBe(true)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3) expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(4)
}) })
it('shows the display name in the header and the chip when there is one', async () => { it('shows the display name in the header and the chip when there is one', async () => {
@@ -297,7 +305,15 @@ describe('The collapsed rail keeps the menu and an accessible name', () => {
await openMenu() await openMenu()
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out']) expect(itemLabels()).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
}) })
}) })
@@ -0,0 +1,840 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ui from '@nuxt/ui/vue-plugin'
import type { Component } from 'vue'
import App from '@/App.vue'
import AuthShell from '@/components/AuthShell.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import UserMenu from '@/components/UserMenu.vue'
import { useApi } from '@/composables/useApi'
import { useColorMode } from '@/composables/useColorMode'
import { useSessionStore } from '@/stores/session'
import { initializeLocale, locale, setLocale, t } from '@/i18n'
import { messages } from '@/i18n/messages'
import router from '@/router'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue')
return {
useApi: vi.fn(),
authRedirectPending: ref(false),
ApiError: class extends Error {
constructor(
public status: number,
public retryAfter?: number,
public body?: string,
) {
super('API failure')
}
},
}
})
const request = vi.fn()
const SIGNED_IN = {
id: 1,
verified: true,
username: 'marta',
displayName: '',
email: 'marta@renew.example',
role: 'owner',
}
let wrapper: VueWrapper | undefined
function signIn() {
const session = useSessionStore()
session.session = SIGNED_IN
session.status = 'ready'
}
function signOut() {
const session = useSessionStore()
session.session = null
session.status = 'ready'
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
useColorMode().preference = 'light'
request.mockReset().mockResolvedValue([])
vi.mocked(useApi).mockReturnValue({
request,
getMe: vi.fn().mockResolvedValue(null),
} as unknown as ReturnType<typeof useApi>)
})
afterEach(async () => {
vi.restoreAllMocks()
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
localStorage.clear()
initializeLocale()
await router.push('/login')
})
function press(element: Element, value: string) {
element.dispatchEvent(
new KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true }),
)
}
function pointerActivate(element: Element) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
function clickOutside() {
const outside = document.createElement('button')
document.body.append(outside)
outside.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
outside.click()
}
function menu() {
return document.body.querySelector<HTMLElement>('[role="menu"]')
}
function rows(container: ParentNode = document.body) {
return Array.from(
container.querySelectorAll<HTMLElement>('[role="menuitem"], [role="menuitemradio"]'),
)
}
function row(label: string) {
return rows().find((item) => item.textContent?.trim() === label)!
}
function checked(items: HTMLElement[]) {
return items.filter((item) => item.getAttribute('aria-checked') === 'true')
}
function sectionLabel(text: string) {
return Array.from(menu()!.querySelectorAll<HTMLElement>('div')).find(
(el) => el.textContent?.trim() === text && el.className.includes('uppercase'),
)!
}
async function nextFrame() {
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
}
type Surface = 'desktop UserMenu popover' | 'mobile AccountMenuSheet'
const surfaces: Surface[] = ['desktop UserMenu popover', 'mobile AccountMenuSheet']
async function openAccountMenu(surface: Surface) {
signIn()
await router.push('/profile')
if (surface === 'desktop UserMenu popover') {
wrapper = mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', email: SIGNED_IN.email, collapsed: false },
global: { plugins: [ui, router] },
})
document.body.querySelector<HTMLButtonElement>('button[aria-haspopup]')!.click()
} else {
wrapper = mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', email: SIGNED_IN.email, open: true },
global: { plugins: [ui, router] },
})
}
await nextFrame()
}
function authChip() {
return document.body.querySelector<HTMLButtonElement>('footer button[aria-haspopup="menu"]')!
}
async function mountAuthShell(belowSm: boolean, slots: Record<string, string> = {}) {
useColorMode().preference = 'system'
setViewportWidth(belowSm ? MOBILE_WIDTH : DESKTOP_WIDTH)
wrapper = mount(AuthShell, {
attachTo: document.body,
props: { title: 'Log in' },
slots,
global: { plugins: [ui] },
})
await flushPromises()
}
describe('Account menu lists Profile, Theme, Language and Log out in design order', () => {
it.each(surfaces)('orders the entries in the %s', async (surface) => {
await openAccountMenu(surface)
const text = menu()!.textContent!
const order = [
'Profile',
'Theme',
'System',
'Light',
'Dark',
'Language',
'English',
'Français',
'Log out',
]
const positions = order.map((entry) =>
text.indexOf(entry, entry === 'Language' ? text.indexOf('Dark') : 0),
)
expect(positions).toEqual([...positions].sort((a, b) => a - b))
expect(positions.every((position) => position >= 0)).toBe(true)
expect(rows().map((item) => item.textContent?.trim())).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it.each(surfaces)(
'styles the section labels and removes the old picker in the %s',
async (surface) => {
await openAccountMenu(surface)
for (const label of ['Theme', 'Language']) {
const classes = sectionLabel(label).className
expect(classes).toContain('text-[0.6875rem]')
expect(classes).toContain('font-medium')
expect(classes).toContain('uppercase')
expect(classes).toContain('tracking-[0.04em]')
expect(classes).toContain('text-muted')
expect(classes).toContain(
surface === 'desktop UserMenu popover' ? 'pt-1.5' : 'pt-2',
)
expect(classes).toContain(surface === 'desktop UserMenu popover' ? 'px-2' : 'px-4')
}
expect(document.body.querySelector('[role="combobox"], [role="listbox"]')).toBeNull()
expect(
menu()!.querySelector('[data-icon="languages"], .iconify--lucide--languages'),
).toBeNull()
},
)
it.each(surfaces)('gives every row the design height in the %s', async (surface) => {
await openAccountMenu(surface)
for (const item of rows()) {
expect(item.className).toContain(
surface === 'desktop UserMenu popover' ? 'h-8' : 'h-12',
)
}
})
})
describe('Language rows are a radio group naming each language in its own name', () => {
it.each(
surfaces.flatMap((surface) => [
[surface, 'en', 'English'],
[surface, 'fr', 'Français'],
]),
)('in the %s with locale %s checks %s', async (surface, code, name) => {
setLocale(code as 'en' | 'fr')
await openAccountMenu(surface as Surface)
const group = menu()!.querySelector<HTMLElement>(
`[role="group"][aria-labelledby="${sectionLabel(t('Language')).id}"]`,
)!
const languageRows = rows(group)
expect(languageRows.map((item) => item.getAttribute('role'))).toEqual([
'menuitemradio',
'menuitemradio',
])
expect(
languageRows.map((item) => [item.textContent?.trim(), item.getAttribute('lang')]),
).toEqual([
['English', 'en'],
['Français', 'fr'],
])
expect(checked(languageRows).map((item) => item.textContent?.trim())).toEqual([name])
expect(
languageRows.filter((item) =>
item.querySelector('svg[data-slot="itemTrailingIcon"], svg.iconify'),
).length,
).toBe(1)
expect(sectionLabel(t('Language')).textContent).toBe(code === 'fr' ? 'Langue' : 'Language')
expect(sectionLabel(t('Theme')).textContent).toBe(code === 'fr' ? 'Thème' : 'Theme')
})
})
describe('Theme rows are radio items that keep the menu open', () => {
it.each([
['desktop UserMenu popover', 'light'],
['mobile AccountMenuSheet', 'dark'],
] as const)('in the %s with preference %s', async (surface, preference) => {
useColorMode().preference = preference
await openAccountMenu(surface)
const themeRows = ['System', 'Light', 'Dark'].map(row)
expect(themeRows.map((item) => item.getAttribute('role'))).toEqual([
'menuitemradio',
'menuitemradio',
'menuitemradio',
])
expect(checked(themeRows).map((item) => item.textContent?.trim())).toEqual([
preference === 'light' ? 'Light' : 'Dark',
])
pointerActivate(row('System'))
await flushPromises()
expect(useColorMode().preference).toBe('system')
expect(menu()).not.toBeNull()
expect(row('System').getAttribute('aria-checked')).toBe('true')
})
})
describe('Choosing a language in the account menu applies it at once and keeps the menu open', () => {
it.each(surfaces)('in the %s', async (surface) => {
await openAccountMenu(surface)
request.mockClear()
pointerActivate(row('Français'))
await flushPromises()
expect([
locale.value,
document.documentElement.lang,
localStorage.getItem('renewcms-language'),
menu() !== null,
row('Français').getAttribute('aria-checked'),
row('English').getAttribute('aria-checked'),
sectionLabel('Thème') !== undefined,
sectionLabel('Langue') !== undefined,
row('Profil') !== undefined,
request,
router.currentRoute.value.path,
useColorMode().preference,
]).toEqual([
'fr',
'fr',
'fr',
true,
'true',
'false',
true,
true,
true,
expect.objectContaining({ mock: expect.objectContaining({ calls: [] }) }),
'/profile',
'light',
])
pointerActivate(row('English'))
await flushPromises()
expect([locale.value, document.documentElement.lang, menu() !== null]).toEqual([
'en',
'en',
true,
])
})
it.each(['desktop', 'mobile'] as const)(
'keeps an unsaved display name on /profile in the %s layout',
async (layout) => {
signIn()
setViewportWidth(layout === 'desktop' ? DESKTOP_WIDTH : MOBILE_WIDTH)
await router.push('/profile')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
await wrapper.find('input[autocomplete="off"]').setValue('Alice B.')
if (layout === 'mobile') {
document.body.querySelector<HTMLButtonElement>('[data-slot="toggle"]')!.click()
await flushPromises()
}
document.body
.querySelector<HTMLButtonElement>(
layout === 'mobile'
? '[role="dialog"] button[aria-haspopup]'
: 'button[aria-haspopup]',
)!
.click()
await nextFrame()
request.mockClear()
pointerActivate(row('Français'))
await flushPromises()
expect([
locale.value,
(wrapper.find('input[autocomplete="off"]').element as HTMLInputElement).value,
router.currentRoute.value.path,
request.mock.calls.length,
menu() !== null,
]).toEqual(['fr', 'Alice B.', '/profile', 0, true])
},
)
it('applies the language for the session when storage cannot be written', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await openAccountMenu('desktop UserMenu popover')
pointerActivate(row('Français'))
await flushPromises()
expect([locale.value, row('Français').getAttribute('aria-checked')]).toEqual(['fr', 'true'])
})
})
describe('Account menu keyboard and dismissal', () => {
it('wraps the arrow keys across every entry of the desktop popover', async () => {
await openAccountMenu('desktop UserMenu popover')
const items = rows()
items[items.length - 1]!.focus()
press(document.activeElement!, 'ArrowDown')
await flushPromises()
expect(document.activeElement).toBe(items[0])
press(document.activeElement!, 'ArrowUp')
await flushPromises()
expect(document.activeElement).toBe(items[items.length - 1])
const visited: (string | undefined)[] = []
items[0]!.focus()
for (let step = 0; step < items.length; step++) {
visited.push(document.activeElement?.textContent?.trim())
press(document.activeElement!, 'ArrowDown')
await flushPromises()
}
expect(visited).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it('wraps the arrow keys across every entry of the mobile sheet', async () => {
await openAccountMenu('mobile AccountMenuSheet')
const items = rows()
items[items.length - 1]!.focus()
press(document.activeElement!, 'ArrowDown')
expect(document.activeElement).toBe(items[0])
press(document.activeElement!, 'ArrowUp')
expect(document.activeElement).toBe(items[items.length - 1])
const visited: (string | undefined)[] = []
items[0]!.focus()
for (let step = 0; step < items.length; step++) {
visited.push(document.activeElement?.textContent?.trim())
press(document.activeElement!, 'ArrowDown')
}
expect(visited).toEqual([
'Profile',
'System',
'Light',
'Dark',
'English',
'Français',
'Log out',
])
})
it('applies a language with Enter and Space in the desktop popover and keeps it open', async () => {
await openAccountMenu('desktop UserMenu popover')
row('Français').focus()
press(row('Français'), 'Enter')
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['fr', true])
row('English').focus()
press(row('English'), ' ')
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['en', true])
})
it('renders the mobile language rows as native buttons so Enter and Space activate them', async () => {
await openAccountMenu('mobile AccountMenuSheet')
expect([row('English').tagName, row('Français').tagName]).toEqual(['BUTTON', 'BUTTON'])
row('Français').click()
await flushPromises()
expect([locale.value, menu() !== null]).toEqual(['fr', true])
})
it.each(surfaces)(
'closes with Escape without changing the language in the %s',
async (surface) => {
await openAccountMenu(surface)
row('Profile').focus()
press(document.activeElement!, 'Escape')
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
},
)
it('closes the desktop popover on a click outside without changing the language', async () => {
await openAccountMenu('desktop UserMenu popover')
clickOutside()
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
})
it('closes the mobile sheet on a tap on the backdrop without changing the language', async () => {
await openAccountMenu('mobile AccountMenuSheet')
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
overlay.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
overlay.click()
await nextFrame()
expect([menu(), locale.value]).toEqual([null, 'en'])
})
})
describe('Auth footer shows the language chip beside the theme chip', () => {
it.each([false, true])(
'renders both chips, aligned right, with belowSm=%s',
async (belowSm) => {
await mountAuthShell(belowSm)
const footer = document.body.querySelector('footer')!
const chips = footer.lastElementChild as HTMLElement
expect(footer).not.toBeNull()
expect(chips.className).toContain('gap-2')
expect(chips.children).toHaveLength(2)
expect(chips.children[0]).toBe(authChip())
expect(chips.children[1]!.textContent).toContain('System')
expect(authChip().textContent?.trim()).toBe('English')
expect(authChip().getAttribute('aria-haspopup')).toBe('menu')
expect(authChip().getAttribute('aria-expanded')).toBe('false')
expect(authChip().getAttribute('aria-label')).toBe('Language: English')
expect(authChip().querySelector('.iconify--lucide, svg')).not.toBeNull()
expect(authChip().className).toContain('border-accented')
expect(authChip().className).toContain('text-toned')
expect(authChip().className).toContain(belowSm ? 'h-11' : 'h-7')
expect(
document.body.querySelector('[role="combobox"][aria-label*="anguage"]'),
).toBeNull()
},
)
it('keeps the footer slot content to the left of the chips', async () => {
await mountAuthShell(false, { footer: '<a href="/register">Create an account</a>' })
const footer = document.body.querySelector('footer')!
const link = footer.querySelector('a')!
expect(footer.firstElementChild!.contains(link)).toBe(true)
expect(
footer.firstElementChild!.compareDocumentPosition(authChip()) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('reflects the stored language in the chip and its label', async () => {
localStorage.setItem('renewcms-language', 'fr')
initializeLocale()
await mountAuthShell(false)
expect([authChip().textContent?.trim(), authChip().getAttribute('aria-label')]).toEqual([
'Français',
'Langue : Français',
])
})
})
describe('Auth language popover', () => {
async function openPopover() {
await mountAuthShell(false)
authChip().click()
await nextFrame()
}
it.each([
['English', 'en'],
['Français', 'fr'],
])('opens a named menu with two radio rows when the language is %s', async (name, code) => {
setLocale(code as 'en' | 'fr')
await openPopover()
expect(menu()!.getAttribute('aria-label')).toBe(code === 'fr' ? 'Langue' : 'Language')
expect(authChip().getAttribute('aria-expanded')).toBe('true')
expect(authChip().className).toContain('bg-elevated')
expect(
rows().map((item) => [
item.getAttribute('role'),
item.textContent?.trim(),
item.getAttribute('lang'),
]),
).toEqual([
['menuitemradio', 'English', 'en'],
['menuitemradio', 'Français', 'fr'],
])
expect(checked(rows()).map((item) => item.textContent?.trim())).toEqual([name])
expect(menu()!.className).toContain('w-42')
expect(menu()!.className).toContain('p-1')
rows().forEach((item) => {
expect(item.className).toContain('h-8')
expect(item.className).toContain('text-base')
})
})
it.each(['Enter', ' ', 'ArrowDown'])(
'opens with %j on the chip and focuses the current row',
async (keyName) => {
await mountAuthShell(false)
authChip().focus()
press(authChip(), keyName)
await nextFrame()
expect(menu()).not.toBeNull()
expect(document.activeElement).toBe(row('English'))
},
)
it('moves between the two rows with the arrow keys and chooses with Enter', async () => {
await openPopover()
press(document.activeElement!, 'ArrowDown')
await flushPromises()
expect(document.activeElement).toBe(row('Français'))
press(document.activeElement!, 'ArrowUp')
await flushPromises()
expect(document.activeElement).toBe(row('English'))
row('Français').focus()
press(row('Français'), 'Enter')
await nextFrame()
expect(locale.value).toBe('fr')
})
it('applies French on the whole screen, closes and returns focus to the chip', async () => {
signOut()
await router.push('/login')
const { default: view } = await import('@/views/LoginView.vue')
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
authChip().focus()
authChip().click()
await nextFrame()
request.mockClear()
pointerActivate(row('Français'))
await nextFrame()
expect(locale.value).toBe('fr')
expect(document.documentElement.lang).toBe('fr')
expect(localStorage.getItem('renewcms-language')).toBe('fr')
expect(document.title).toBe('Se connecter · RenewCMS')
expect(document.body.textContent).toContain('Mot de passe')
expect(menu()).toBeNull()
expect(document.activeElement).toBe(authChip())
expect(authChip().textContent?.trim()).toBe('Français')
expect(request).not.toHaveBeenCalled()
})
it('dismisses with Escape, keeping the language and returning focus to the chip', async () => {
await openPopover()
press(document.activeElement!, 'Escape')
await nextFrame()
expect([
menu(),
locale.value,
authChip().getAttribute('aria-expanded'),
document.activeElement,
]).toEqual([null, 'en', 'false', authChip()])
})
it('dismisses on a click outside, keeping the language', async () => {
await openPopover()
clickOutside()
await nextFrame()
expect([menu(), locale.value, authChip().getAttribute('aria-expanded')]).toEqual([
null,
'en',
'false',
])
})
})
describe('Each auth screen carries the language chip', () => {
const screens: [string, () => Promise<{ default: Component }>, string][] = [
['/login', () => import('@/views/LoginView.vue'), '/login'],
['/register', () => import('@/views/RegisterView.vue'), '/register'],
['/register/pending', () => import('@/views/PendingView.vue'), '/register/pending'],
['/register/validate', () => import('@/views/VerifyView.vue'), '/register/validate'],
[
'/register/reset/request',
() => import('@/views/ResetRequestView.vue'),
'/register/reset/request',
],
[
'/register/reset/validate',
() => import('@/views/ResetConfirmView.vue'),
'/register/reset/validate',
],
['/invite/accept', () => import('@/views/AcceptInviteView.vue'), '/invite/accept'],
]
it.each(screens)('%s', async (_name, load, path) => {
const session = useSessionStore()
session.session =
path === '/register/pending'
? { verified: false, username: 'marta', email: SIGNED_IN.email }
: null
session.status = 'ready'
await router.push(path)
const { default: view } = await load()
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
expect(authChip().textContent?.trim()).toBe('English')
expect(authChip().getAttribute('aria-label')).toBe('Language: English')
expect(document.body.querySelector('[role="combobox"][aria-label*="anguage"]')).toBeNull()
})
it('keeps typed login values when the language changes', async () => {
signOut()
await router.push('/login')
const { default: view } = await import('@/views/LoginView.vue')
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
await flushPromises()
await wrapper.find('input[autocomplete="username"]').setValue('marta')
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
expect([
(wrapper.find('input[autocomplete="username"]').element as HTMLInputElement).value,
router.currentRoute.value.path,
]).toEqual(['marta', '/login'])
})
})
describe('Language persists across public and signed-in screens', () => {
it('keeps Français after signing in, in the account menu and back on the login chip', async () => {
await mountAuthShell(false)
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
wrapper!.unmount()
wrapper = undefined
document.body.innerHTML = ''
await openAccountMenu('desktop UserMenu popover')
expect(row('Français').getAttribute('aria-checked')).toBe('true')
wrapper!.unmount()
wrapper = undefined
document.body.innerHTML = ''
await mountAuthShell(false)
expect(authChip().textContent?.trim()).toBe('Français')
})
it('restores a stored French language on the chip after a reload', async () => {
localStorage.setItem('renewcms-language', 'fr')
initializeLocale()
await mountAuthShell(false)
expect(authChip().textContent?.trim()).toBe('Français')
})
it('still switches for the session when the storage write fails', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await mountAuthShell(false)
authChip().click()
await nextFrame()
pointerActivate(row('Français'))
await nextFrame()
expect([locale.value, authChip().textContent?.trim()]).toEqual(['fr', 'Français'])
})
})
describe('Catalog parity for the new strings', () => {
it('translates Theme and Language: {0} and keeps the parameter', () => {
expect(messages['Theme']).toBe('Thème')
expect(messages['Language: {0}']).toBe('Langue : {0}')
expect(messages['Language: {0}']).toContain('{0}')
})
it('never translates the native language names', () => {
expect(Object.keys(messages)).not.toContain('English')
expect(Object.keys(messages)).not.toContain('Français')
})
})
describe('Radio role patching releases its observer', () => {
async function mountProbe() {
const { defineComponent, h, ref } = await import('vue')
const { useLanguageMenuRadios } = await import('@/composables/useRadioMenuRoles')
const open = ref(false)
const probe = defineComponent({
setup() {
useLanguageMenuRadios(open)
return () => h('div')
},
})
return { open, probe: mount(probe) }
}
it('observes only while open and disconnects on close', async () => {
const observe = vi.spyOn(MutationObserver.prototype, 'observe')
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
const { open, probe } = await mountProbe()
expect(observe).not.toHaveBeenCalled()
open.value = true
await flushPromises()
expect(observe).toHaveBeenCalledTimes(1)
expect(disconnect).not.toHaveBeenCalled()
open.value = false
await flushPromises()
expect(disconnect).toHaveBeenCalledTimes(1)
probe.unmount()
expect(disconnect).toHaveBeenCalledTimes(1)
})
it('disconnects when unmounted while open', async () => {
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
const { open, probe } = await mountProbe()
open.value = true
await flushPromises()
probe.unmount()
expect(disconnect).toHaveBeenCalledTimes(1)
})
})
@@ -33,8 +33,7 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin(path = '/login') { async function mountLogin(path = '/login') {
setActivePinia(createPinia()) setActivePinia(createPinia())
global.fetch = (() => global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push(path) await router.push(path)
const wrapper = mount(LoginView, { const wrapper = mount(LoginView, {
attachTo: document.body, attachTo: document.body,
@@ -78,9 +77,7 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
const fieldRoots = wrapper.findAll( const fieldRoots = wrapper.findAll('[data-slot="root"][data-orientation]')
'[data-slot="root"][data-orientation]',
)
const usernameError = fieldRoots[0]!.find('[data-slot="error"]') const usernameError = fieldRoots[0]!.find('[data-slot="error"]')
const passwordError = fieldRoots[1]!.find('[data-slot="error"]') const passwordError = fieldRoots[1]!.find('[data-slot="error"]')
@@ -97,14 +94,12 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
expect(usernameInput.classes()).toContain('ring-error') expect(usernameInput.classes()).toContain('ring-error')
expect(usernameInput.classes()).toContain('focus-visible:ring-error') expect(usernameInput.classes()).toContain('focus-visible:ring-error')
expect(usernameInput.classes()).not.toContain('ring-primary') expect(usernameInput.classes()).not.toContain('ring-primary')
expect(usernameInput.classes()).not.toContain( expect(usernameInput.classes()).not.toContain('focus-visible:ring-primary')
'focus-visible:ring-primary',
)
}) })
}) })
describe('The theme select is right-aligned in the footer with no chevron', () => { describe('The theme select is right-aligned in the footer with no chevron', () => {
it('renders as the last child, showing only the mode icon and label', async () => { it('renders as the last chip, showing only the mode icon and label', async () => {
stubMatchMedia(false) stubMatchMedia(false)
const wrapper = await mountLogin() const wrapper = await mountLogin()
@@ -112,8 +107,8 @@ describe('The theme select is right-aligned in the footer with no chevron', () =
const select = footer.find('button[aria-haspopup="listbox"]') const select = footer.find('button[aria-haspopup="listbox"]')
expect(select.exists()).toBe(true) expect(select.exists()).toBe(true)
const children = Array.from(footer.element.children) const chips = footer.element.lastElementChild as HTMLElement
expect(children[children.length - 1]).toBe(select.element) expect(chips.lastElementChild).toBe(select.element)
expect(select.text()).toContain('System') expect(select.text()).toContain('System')
@@ -146,31 +141,19 @@ describe('The alert renders inside the card, under the description', () => {
expect(card.element.contains(alert.element)).toBe(true) expect(card.element.contains(alert.element)).toBe(true)
expect(alert.text()).toContain(text) expect(alert.text()).toContain(text)
const rootsInCard = card.findAll( const rootsInCard = card.findAll('[data-slot="root"][data-orientation]')
'[data-slot="root"][data-orientation]', expect(rootsInCard.some((root) => root.classes().includes('bg-primary'))).toBe(false)
)
expect(
rootsInCard.some((root) =>
root.classes().includes('bg-primary'),
),
).toBe(false)
const usernameInput = wrapper.find( const usernameInput = wrapper.find('input[placeholder="Your username"]')
'input[placeholder="Your username"]',
)
const html = card.html() const html = card.html()
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1) expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
expect(html.indexOf('Welcome back')).toBeLessThan( expect(html.indexOf('Welcome back')).toBeLessThan(html.indexOf(text))
html.indexOf(text),
)
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username')) expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true) expect(card.element.contains(usernameInput.element)).toBe(true)
expect( expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
alert true,
.classes() )
.some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)),
).toBe(true)
}, },
) )
}) })
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const request = vi.fn()
vi.mock('@/composables/useApi', () => ({
useApi: () => ({ request }),
}))
import { useArticles } from '@/composables/useArticles'
describe('useArticles', () => {
beforeEach(() => {
request.mockReset()
})
it('asks the API for html bodies when listing articles', async () => {
request.mockResolvedValue([])
await useArticles().listArticles()
expect(request).toHaveBeenCalledWith('/v1/article/?format=html')
})
})
+1 -1
View File
@@ -79,7 +79,7 @@ export function useArticleDraft(articleId: number) {
async function save(): Promise<SaveOutcome> { async function save(): Promise<SaveOutcome> {
businessState.value = 'saving' businessState.value = 'saving'
try { try {
await request<Article>(`/v1/article/${articleId}`, { await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: body.value }), body: JSON.stringify({ body: body.value }),
+1 -1
View File
@@ -13,7 +13,7 @@ export function useArticles() {
const { request } = useApi() const { request } = useApi()
function listArticles(): Promise<Article[]> { function listArticles(): Promise<Article[]> {
return request<Article[]>('/v1/article/') return request<Article[]>('/v1/article/?format=html')
} }
return { listArticles } return { listArticles }
+15
View File
@@ -0,0 +1,15 @@
import { computed } from 'vue'
import { locale, setLocale, type Locale } from '@/i18n'
export const languageOptions: { label: string; value: Locale }[] = [
{ label: 'English', value: 'en' },
{ label: 'Français', value: 'fr' },
]
export const languageName = computed(
() => languageOptions.find((option) => option.value === locale.value)?.label ?? 'English',
)
export function chooseLanguage(value: Locale) {
setLocale(value)
}
+63
View File
@@ -0,0 +1,63 @@
import { onBeforeUnmount, watch, type Ref } from 'vue'
import { t } from '@/i18n'
import { languageOptions } from '@/composables/useLanguage'
export const LANGUAGE_MENU_CLASS = 'rc-language-menu'
function groupLabel(group: Element) {
const id = group.getAttribute('aria-labelledby')
return id ? document.getElementById(id)?.textContent?.trim() : undefined
}
function toRadio(item: Element) {
item.setAttribute('role', 'menuitemradio')
const language = languageOptions.find((option) => option.label === item.textContent?.trim())
if (language) item.setAttribute('lang', language.value)
}
function radiofyAccountMenu() {
const labels = [t('Theme'), t('Language')]
for (const group of document.querySelectorAll('[role="menu"] [role="group"]')) {
const label = groupLabel(group)
if (!label || !labels.includes(label)) continue
group.querySelectorAll('[role="menuitemcheckbox"]').forEach(toRadio)
}
}
function radiofyLanguageMenu() {
for (const menu of document.querySelectorAll(`[role="menu"].${LANGUAGE_MENU_CLASS}`)) {
menu.setAttribute('aria-label', t('Language'))
menu.querySelectorAll('[role="menuitemcheckbox"]').forEach(toRadio)
}
}
function applyWhileOpen(open: Ref<boolean>, apply: () => void) {
let observer: MutationObserver | null = null
function stop() {
observer?.disconnect()
observer = null
}
watch(
open,
(value) => {
stop()
if (!value) return
apply()
observer = new MutationObserver(apply)
observer.observe(document.body, { childList: true, subtree: true })
},
{ immediate: true },
)
onBeforeUnmount(stop)
}
export function useAccountMenuRadios(open: Ref<boolean>) {
applyWhileOpen(open, radiofyAccountMenu)
}
export function useLanguageMenuRadios(open: Ref<boolean>) {
applyWhileOpen(open, radiofyLanguageMenu)
}
+2
View File
@@ -4,6 +4,8 @@ export const messages = {
unselected: 'désélectionnée', unselected: 'désélectionnée',
'Log in': 'Se connecter', 'Log in': 'Se connecter',
Language: 'Langue', Language: 'Langue',
Theme: 'Thème',
'Language: {0}': 'Langue : {0}',
Profile: 'Profil', Profile: 'Profil',
'Log out': 'Se déconnecter', 'Log out': 'Se déconnecter',
Account: 'Compte', Account: 'Compte',
+3 -220
View File
@@ -3,16 +3,13 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import ui from '@nuxt/ui/vue-plugin' import ui from '@nuxt/ui/vue-plugin'
import App from '@/App.vue' import App from '@/App.vue'
import LanguagePicker from '@/components/LanguagePicker.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import UserMenu from '@/components/UserMenu.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import { useApi, ApiError } from '@/composables/useApi' import { useApi, ApiError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { useColorMode } from '@/composables/useColorMode' import { useColorMode } from '@/composables/useColorMode'
import router from '@/router' import router from '@/router'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { initializeLocale, locale, setLocale, t } from './index' import { initializeLocale, setLocale, t } from './index'
import { messages } from './messages' import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate' import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat' import { formatSize, pluralArticles } from '@/lib/mediaFormat'
@@ -84,22 +81,6 @@ async function mountRoute(path: string) {
return wrapper return wrapper
} }
function trigger() {
return document.body.querySelector<HTMLButtonElement>(
'[role="combobox"][aria-label="Language"], [role="combobox"][aria-label="Langue"]',
)!
}
function key(element: Element, value: string) {
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: value,
bubbles: true,
cancelable: true,
}),
)
}
describe('Translate the entire interface', () => { describe('Translate the entire interface', () => {
it('provides nonempty equivalent interpolation parameters for every English and French key', () => { it('provides nonempty equivalent interpolation parameters for every English and French key', () => {
const placeholders = (text: string) => const placeholders = (text: string) =>
@@ -321,152 +302,6 @@ describe('Switch language without losing work', () => {
}) })
}) })
describe('Operate the picker accessibly', () => {
it.each([false, true])(
'supports keyboard selection and focus return with mobile=%s',
async (mobile) => {
wrapper = mount(LanguagePicker, {
attachTo: document.body,
props: { mobile },
global: { plugins: [ui] },
})
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
)
const selected = options
.find((option) => option.textContent!.includes('English'))
?.getAttribute('aria-selected')
key(document.activeElement!, 'End')
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
key(document.activeElement!, 'Enter')
await flushPromises()
expect({
options: options.map((option) => option.textContent!.trim()),
selected,
locale: locale.value,
closed: !document.body.querySelector('[role="listbox"]'),
focus: document.activeElement === trigger(),
}).toEqual({
options: ['English', 'Français'],
selected: 'true',
locale: 'fr',
closed: true,
focus: true,
})
},
)
it('dismisses with Escape and preserves the language', async () => {
wrapper = mount(LanguagePicker, {
attachTo: document.body,
global: { plugins: [ui] },
})
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'Escape')
await flushPromises()
expect([
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]).toEqual(['en', true, true])
})
it.each(['desktop', 'mobile'])('offers the picker in the %s account menu', async (menu) => {
useSessionStore().session = {
verified: true,
username: 'marta',
displayName: '',
}
useSessionStore().status = 'ready'
await router.push('/profile')
wrapper =
menu === 'desktop'
? mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', collapsed: false },
global: { plugins: [ui, router] },
})
: mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', open: true },
global: { plugins: [ui, router] },
})
if (menu === 'desktop')
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
await flushPromises()
const profileEntry = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
profileEntry.focus()
key(profileEntry, menu === 'desktop' ? 'End' : 'ArrowDown')
await new Promise((resolve) => setTimeout(resolve, 0))
const reachedControl = document.activeElement === trigger()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'End')
await new Promise((resolve) => setTimeout(resolve, 0))
key(document.activeElement!, 'Enter')
await flushPromises()
expect([
reachedControl,
locale.value,
document.activeElement === trigger(),
trigger().textContent?.includes('Langue'),
]).toEqual([true, 'fr', true, true])
})
it('retains French after logout and renders the public authentication screen in French', async () => {
setLocale('fr')
await mountRoute('/profile')
document.body.querySelector<HTMLButtonElement>('button[title="Compte"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => item.textContent?.trim() === 'Se déconnecter')!
.click()
await flushPromises()
expect([
router.currentRoute.value.path,
locale.value,
localStorage.getItem('renewcms-language'),
document.body.textContent!.includes('Se connecter'),
]).toEqual(['/login', 'fr', 'fr', true])
})
it('updates an existing success notification without translating the saved account name', async () => {
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Log in')
request.mockResolvedValueOnce({ display_name: 'Log in' })
await wrapper!.find('form').trigger('submit')
await flushPromises()
setLocale('fr')
await flushPromises()
expect([
document.body.textContent!.includes('Nom d’affichage enregistré'),
document.body.textContent!.includes(
'Vous apparaissez désormais sous le nom Log in dans RenewCMS et les e-mails.',
),
]).toEqual([true, true])
})
it('updates an already-open confirmation dialog', async () => {
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Unsaved')
const navigation = router.push('/home')
await flushPromises()
setLocale('fr')
await flushPromises()
const translated = document.body
.querySelector('[role="alertdialog"]')
?.textContent?.includes('Quitter sans enregistrer ?')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
.find((button) => button.textContent?.trim() === 'Abandonner les modifications')!
.click()
await navigation
expect(translated).toBe(true)
})
})
describe('Hardener localization outcomes', () => { describe('Hardener localization outcomes', () => {
it.each([false, true])( it.each([false, true])(
'updates not-found breadcrumbs round-trip with authenticated=%s', 'updates not-found breadcrumbs round-trip with authenticated=%s',
@@ -558,63 +393,11 @@ describe('Hardener localization outcomes', () => {
]).toEqual([window.location.origin, true, true, true]) ]).toEqual([window.location.origin, true, true, true])
await vi.waitFor(() => await vi.waitFor(() =>
expect(document.body.textContent).toContain( expect(document.body.textContent).toContain(
`const res = await fetch('${window.location.origin}/v1/article/')`, `const res = await fetch('${window.location.origin}/v1/article/?format=html')`,
), ),
) )
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
it.each(['desktop', 'mobile'] as const)(
'dismisses nested picker with Escape and outside press in %s menu',
async (menu) => {
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
useSessionStore().status = 'ready'
await router.push('/profile')
wrapper =
menu === 'desktop'
? mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', collapsed: false },
global: { plugins: [ui, router] },
})
: mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', open: true },
global: { plugins: [ui, router] },
})
if (menu === 'desktop')
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
await flushPromises()
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'Escape')
await flushPromises()
const escape = [
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
const outside = document.createElement('button')
outside.textContent = 'Outside'
document.body.append(outside)
outside.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
outside.click()
await flushPromises()
expect([
escape,
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]).toEqual([['en', true, true], 'en', true, true])
},
)
it.each(['/article', '/article/12/edit'])( it.each(['/article', '/article/12/edit'])(
'updates already-open delete descriptions round-trip from %s preserving title and image sentence', 'updates already-open delete descriptions round-trip from %s preserving title and image sentence',
async (path) => { async (path) => {
@@ -628,7 +411,7 @@ describe('Hardener localization outcomes', () => {
updated_at: '2025-02-14T12:00:00Z', updated_at: '2025-02-14T12:00:00Z',
} }
request.mockImplementation(async (url: string) => request.mockImplementation(async (url: string) =>
url === '/v1/article/' ? [article] : article, url === '/v1/article/?format=html' ? [article] : article,
) )
await mountRoute(path) await mountRoute(path)
if (path === '/article') { if (path === '/article') {
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { ArticleIndent } from '../articleIndent'
import { ArticleTextAlign } from '../articleAlign'
function createEditor(content: string) {
return new Editor({
extensions: [StarterKit, ArticleIndent, ArticleTextAlign],
content,
})
}
describe('Aligning text writes text-align on paragraphs and headings', () => {
it.each(['center', 'right', 'justify'])('can align a paragraph to the %s', (align) => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
expect(editor.can().setTextAlign(align)).toBe(true)
editor.commands.setTextAlign(align)
expect(editor.getHTML()).toContain(`<p style="text-align: ${align};">Hello</p>`)
expect(editor.isActive({ textAlign: align })).toBe(true)
editor.destroy()
})
it('leaves unaligned paragraphs and headings without a style', () => {
const editor = createEditor('<h2>Title</h2><p>Hello</p>')
expect(editor.getHTML()).toBe('<h2>Title</h2><p>Hello</p>')
editor.destroy()
})
it('aligns a heading', () => {
const editor = createEditor('<h2>Title</h2>')
editor.commands.setTextSelection(2)
editor.commands.setTextAlign('right')
expect(editor.getHTML()).toContain('<h2 style="text-align: right;">Title</h2>')
editor.destroy()
})
it('returns to left alignment', () => {
const editor = createEditor('<p style="text-align: center">Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.setTextAlign('left')
expect(editor.isActive({ textAlign: 'center' })).toBe(false)
expect(editor.getHTML()).not.toContain('center')
editor.destroy()
})
it('loads stored alignment back from HTML', () => {
const editor = createEditor('<p style="text-align: justify">Hello</p>')
editor.commands.setTextSelection(2)
expect(editor.isActive({ textAlign: 'justify' })).toBe(true)
editor.destroy()
})
it('keeps alignment and indentation together', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
editor.commands.setTextAlign('center')
expect(editor.getHTML()).toContain('data-indent="1"')
expect(editor.getHTML()).toContain('text-align: center')
editor.destroy()
})
})
+8
View File
@@ -0,0 +1,8 @@
import TextAlign from '@tiptap/extension-text-align'
export const ALIGN_TYPES = ['heading', 'paragraph']
export const ArticleTextAlign = TextAlign.configure({
types: ALIGN_TYPES,
alignments: ['left', 'center', 'right', 'justify'],
})
+1 -1
View File
@@ -188,7 +188,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true) setBusy(article.id, true)
try { try {
const updated = await request<Article>( const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}`, `/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' }, { method: 'PATCH' },
) )
articles.value = articles.value.map((existing) => articles.value = articles.value.map((existing) =>
+2 -2
View File
@@ -65,7 +65,7 @@ async function loadArticle() {
}, SKELETON_DELAY_MS) }, SKELETON_DELAY_MS)
try { try {
const result = await request<Article>(`/v1/article/${articleId}`) const result = await request<Article>(`/v1/article/${articleId}?format=html`)
article.value = result article.value = result
draft.load(result.body, new Date(result.updated_at)) draft.load(result.body, new Date(result.updated_at))
loadState.value = 'loaded' loadState.value = 'loaded'
@@ -128,7 +128,7 @@ async function setOnline(makeOnline: boolean) {
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…') publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
try { try {
const updated = await request<Article>( const updated = await request<Article>(
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`, `/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' }, { method: 'PATCH' },
) )
article.value = updated article.value = updated
+5 -4
View File
@@ -32,6 +32,7 @@ const sampleArticle = {
updated_at: '2026-03-12T09:14:00Z', updated_at: '2026-03-12T09:14:00Z',
}, },
], ],
body_format: 'html',
is_online: true, is_online: true,
created_at: '2026-03-12T09:14:00Z', created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z', updated_at: '2026-09-10T07:00:00Z',
@@ -47,8 +48,8 @@ const endpoints = computed(() => [
return t('Lists every online article.') return t('Lists every online article.')
}, },
examples: { examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/')\nconst articles = await res.json()`, fetch: `const res = await fetch('${origin.value}/v1/article/?format=html')\nconst articles = await res.json()`,
curl: `curl ${origin.value}/v1/article/`, curl: `curl ${origin.value}/v1/article/?format=html`,
response: JSON.stringify(sampleArticleList, null, 2), response: JSON.stringify(sampleArticleList, null, 2),
}, },
}, },
@@ -61,8 +62,8 @@ const endpoints = computed(() => [
) )
}, },
examples: { examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()`, fetch: `const res = await fetch('${origin.value}/v1/article/12?format=html')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
curl: `curl ${origin.value}/v1/article/12`, curl: `curl ${origin.value}/v1/article/12?format=html`,
response: JSON.stringify(sampleArticle, null, 2), response: JSON.stringify(sampleArticle, null, 2),
}, },
}, },
+26 -26
View File
@@ -192,7 +192,7 @@ afterEach(async () => {
describe('Default — the list loads sorted by last updated, newest first', () => { describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => { it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
}) })
expect(wrapper.text()).toContain('All 6') expect(wrapper.text()).toContain('All 6')
@@ -218,7 +218,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => { it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null let resolveList: (() => void) | null = null
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => 'GET /v1/article/?format=html': () =>
new Promise((resolve) => { new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticleFixture())) resolveList = () => resolve(mockResponse(200, sixArticleFixture()))
}), }),
@@ -239,7 +239,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => { describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => { it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -260,7 +260,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
}) })
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -287,7 +287,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
}) })
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -305,7 +305,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
describe('Empty — zero articles', () => { describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => { it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, []), 'GET /v1/article/?format=html': () => mockResponse(200, []),
}) })
expect(wrapper.text()).toContain('All 0') expect(wrapper.text()).toContain('All 0')
@@ -326,7 +326,7 @@ describe('Empty — zero articles', () => {
describe('Initial load failure — an error must not render the no-articles empty state', () => { describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => { it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(500), 'GET /v1/article/?format=html': () => mockResponse(500),
}) })
expect(wrapper.text()).not.toContain('No articles yet') expect(wrapper.text()).not.toContain('No articles yet')
@@ -336,7 +336,7 @@ describe('Initial load failure — an error must not render the no-articles empt
describe('No results — a search matches nothing, but the tab counts still show the full list', () => { describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => { it('shows the no-results empty state, then clears the search', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
}) })
const searchInput = wrapper.find('input[type="search"]') const searchInput = wrapper.find('input[type="search"]')
@@ -367,7 +367,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4) .slice(0, 4)
.map((a) => ({ ...a, is_online: true })) .map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, onlineOnly), 'GET /v1/article/?format=html': () => mockResponse(200, onlineOnly),
}) })
await clickTab('Drafts 0') await clickTab('Drafts 0')
@@ -384,7 +384,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4) .slice(0, 4)
.map((a) => ({ ...a, is_online: false })) .map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, draftsOnly), 'GET /v1/article/?format=html': () => mockResponse(200, draftsOnly),
}) })
await clickTab('Online 0') await clickTab('Online 0')
@@ -400,8 +400,8 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
describe('Action in progress — publishing a row disables only that row', () => { describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => { it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': () => new Promise(() => {}), 'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -420,8 +420,8 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
updated_at: iso(0), updated_at: iso(0),
} }
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': () => mockResponse(200, updated), 'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -448,8 +448,8 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc
])('shows Retry for $name and re-sends the same request', async ({ handler }) => { ])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler) const patchCall = vi.fn(handler)
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': patchCall, 'PATCH /v1/article/4/publish?format=html': patchCall,
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -487,8 +487,8 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
])('shows Retry for $name and re-sends the same request', async ({ handler }) => { ])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler) const patchCall = vi.fn(handler)
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish': patchCall, 'PATCH /v1/article/3/unpublish?format=html': patchCall,
}) })
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen') await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
@@ -517,8 +517,8 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => { describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => { it('removes the row on a 404 publish response', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/2/publish': () => mockResponse(404), 'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
}) })
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls') await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
@@ -540,7 +540,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
it('removes the row when the delete confirmation resolves 404', async () => { it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/2': () => mockResponse(404), 'DELETE /v1/article/2': () => mockResponse(404),
}) })
@@ -565,8 +565,8 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => { describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => { it('removes the row and updates the tab counts', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish': () => mockResponse(404), 'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
}) })
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen') await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
@@ -590,7 +590,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
describe('Delete confirm — opens the dialog with the row title and image count', () => { describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => { it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -611,7 +611,7 @@ describe('Delete confirm — opens the dialog with the row title and image count
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => { describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => { it('removes the row, recomputes tabs and raises the success toast', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(204), 'DELETE /v1/article/4': () => mockResponse(204),
}) })
@@ -640,7 +640,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => { describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => { it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(500), 'DELETE /v1/article/4': () => mockResponse(500),
}) })
@@ -191,7 +191,7 @@ const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused:
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) { function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
return { return {
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY), 'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
...extra, ...extra,
} }
@@ -57,7 +57,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
session.session = { verified: true, username: 'marta' } session.session = { verified: true, username: 'marta' }
session.status = 'ready' session.status = 'ready'
global.fetch = vi.fn(async (url: string) => { global.fetch = vi.fn(async (url: string) => {
if (url === '/v1/article/12') { if (url === '/v1/article/12?format=html') {
return mockResponse(200, articleFixture(body)) return mockResponse(200, articleFixture(body))
} }
if (url === '/v1/media/?limit=24') { if (url === '/v1/media/?limit=24') {
+37 -37
View File
@@ -147,7 +147,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => { it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => {
let resolveGet: (() => void) | null = null let resolveGet: (() => void) | null = null
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => 'GET /v1/article/12?format=html': () =>
new Promise((resolve) => { new Promise((resolve) => {
resolveGet = () => resolve(mockResponse(200, articleFixture())) resolveGet = () => resolve(mockResponse(200, articleFixture()))
}), }),
@@ -171,7 +171,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => { it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())), 'GET /v1/article/12?format=html': () => Promise.resolve(mockResponse(200, articleFixture())),
}) })
await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(0)
@@ -190,7 +190,7 @@ describe('Saved — a freshly loaded article, draft or online', () => {
'reads the Saved state for is_online $is_online', 'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => { async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture({ is_online })),
}) })
expect(wrapper.text()).toContain('Articles') expect(wrapper.text()).toContain('Articles')
@@ -207,7 +207,7 @@ describe('Saved — a freshly loaded article, draft or online', () => {
describe('Unsaved changes — any edit marks the article dirty', () => { describe('Unsaved changes — any edit marks the article dirty', () => {
it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => { it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(4320)) await typeBody(wrapper, paragraphOfLength(4320))
@@ -222,12 +222,12 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
it('sends the edited body and shows Saving…', async () => { it('sends the edited body and shows Saving…', async () => {
let patchBody: string | null = null let patchBody: string | null = null
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(4320)) await typeBody(wrapper, paragraphOfLength(4320))
global.fetch = vi.fn((url: string, init?: RequestInit) => { global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') { if (routeFor(url, init) === 'PATCH /v1/article/12?format=html') {
patchBody = init?.body as string patchBody = init?.body as string
return new Promise(() => {}) return new Promise(() => {})
} }
@@ -248,13 +248,13 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
describe('Ctrl/⌘ S saves the same way as clicking Save', () => { describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
it('sends the same PATCH on Ctrl+S', async () => { it('sends the same PATCH on Ctrl+S', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(4320)) await typeBody(wrapper, paragraphOfLength(4320))
let patchBody: string | null = null let patchBody: string | null = null
global.fetch = vi.fn((url: string, init?: RequestInit) => { global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') { if (routeFor(url, init) === 'PATCH /v1/article/12?format=html') {
patchBody = init?.body as string patchBody = init?.body as string
return new Promise(() => {}) return new Promise(() => {})
} }
@@ -276,8 +276,8 @@ describe('A successful save returns to the Saved state with the new time', () =>
vi.setSystemTime(new Date('2026-09-26T11:30:00Z')) vi.setSystemTime(new Date('2026-09-26T11:30:00Z'))
let resolvePatch: (() => void) | null = null let resolvePatch: (() => void) | null = null
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => 'PATCH /v1/article/12?format=html': () =>
new Promise((resolve) => { new Promise((resolve) => {
resolvePatch = () => resolvePatch = () =>
resolve( resolve(
@@ -305,7 +305,7 @@ describe('A successful save returns to the Saved state with the new time', () =>
describe('SaveStatus switches to a relative time after an hour', () => { describe('SaveStatus switches to a relative time after an hour', () => {
it('reads "Saved n h ago" once more than an hour has passed', async () => { it('reads "Saved n h ago" once more than an hour has passed', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
expect(wrapper.text()).toContain('Saved at 11:02') expect(wrapper.text()).toContain('Saved at 11:02')
@@ -327,8 +327,8 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
}, },
])('shows Save failed with Retry for $name', async ({ handler }) => { ])('shows Save failed with Retry for $name', async ({ handler }) => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': handler, 'PATCH /v1/article/12?format=html': handler,
}) })
await typeBody(wrapper, paragraph('Revised opening paragraph.')) await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click') await saveButton(wrapper).trigger('click')
@@ -354,8 +354,8 @@ describe('Retry resends the same body and can recover', () => {
), ),
) )
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': patchCall, 'PATCH /v1/article/12?format=html': patchCall,
}) })
await typeBody(wrapper, paragraph('Revised opening paragraph.')) await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click') await saveButton(wrapper).trigger('click')
@@ -373,8 +373,8 @@ describe('Retry resends the same body and can recover', () => {
describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => { describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => {
it('shows the toast and never reads Save failed', async () => { it('shows the toast and never reads Save failed', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(404), 'PATCH /v1/article/12?format=html': () => mockResponse(404),
}) })
await typeBody(wrapper, paragraphOfLength(4320)) await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click') await saveButton(wrapper).trigger('click')
@@ -392,7 +392,7 @@ describe('A save against an article deleted elsewhere shows a toast, not the err
describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => { describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => {
it('shows the warning icon and keeps Save enabled', async () => { it('shows the warning icon and keeps Save enabled', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(9240)) await typeBody(wrapper, paragraphOfLength(9240))
@@ -405,7 +405,7 @@ describe('Near the limit — the counter warns at 9000 characters without blocki
describe('Too long — over 10000 characters disables Save and Publish until shortened', () => { describe('Too long — over 10000 characters disables Save and Publish until shortened', () => {
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => { it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(10214)) await typeBody(wrapper, paragraphOfLength(10214))
@@ -429,7 +429,7 @@ describe('Too long — over 10000 characters disables Save and Publish until sho
describe('Preview shows the rendered title and body without the editing surface', () => { describe('Preview shows the rendered title and body without the editing surface', () => {
it('replaces the editor with a read-only rendering, then restores it unchanged', async () => { it('replaces the editor with a read-only rendering, then restores it unchanged', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => 'GET /v1/article/12?format=html': () =>
mockResponse(200, articleFixture({ body: '<p>Some body</p>' })), mockResponse(200, articleFixture({ body: '<p>Some body</p>' })),
}) })
@@ -454,7 +454,7 @@ describe('Preview shows the rendered title and body without the editing surface'
describe('Offline blocks Save and Publish, and recovers when the connection returns', () => { describe('Offline blocks Save and Publish, and recovers when the connection returns', () => {
it('shows the offline SaveStatus and re-enables Save once back online', async () => { it('shows the offline SaveStatus and re-enables Save once back online', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(4320)) await typeBody(wrapper, paragraphOfLength(4320))
@@ -478,7 +478,7 @@ describe('Offline blocks Save and Publish, and recovers when the connection retu
describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => { describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => {
it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => { it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(4320)) await typeBody(wrapper, paragraphOfLength(4320))
@@ -507,7 +507,7 @@ describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds',
describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => { describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => {
it('keeps the user on the editor with the unsaved body intact', async () => { it('keeps the user on the editor with the unsaved body intact', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(4320)) await typeBody(wrapper, paragraphOfLength(4320))
@@ -528,7 +528,7 @@ describe('Leaving with unsaved changes, "Keep editing" stays on the page', () =>
describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => { describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => {
it('keeps the user on /article/12/edit with the unsaved body intact', async () => { it('keeps the user on /article/12/edit with the unsaved body intact', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
await typeBody(wrapper, paragraphOfLength(4320)) await typeBody(wrapper, paragraphOfLength(4320))
@@ -549,7 +549,7 @@ describe('Esc or the overlay on the leave guard behaves like "Keep editing"', ()
describe('Leaving a clean article never prompts', () => { describe('Leaving a clean article never prompts', () => {
it('navigates directly with no ConfirmDialog', async () => { it('navigates directly with no ConfirmDialog', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
}) })
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article') const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
@@ -564,8 +564,8 @@ describe('Leaving a clean article never prompts', () => {
describe('Session expired mid-save keeps the draft and restores it after logging back in', () => { describe('Session expired mid-save keeps the draft and restores it after logging back in', () => {
it('mirrors the body to sessionStorage and restores it on the next mount', async () => { it('mirrors the body to sessionStorage and restores it on the next mount', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(401), 'PATCH /v1/article/12?format=html': () => mockResponse(401),
}) })
const body = paragraph('Paragraph added just before the session expired.') const body = paragraph('Paragraph added just before the session expired.')
await typeBody(wrapper, body) await typeBody(wrapper, body)
@@ -581,7 +581,7 @@ describe('Session expired mid-save keeps the draft and restores it after logging
session.session = { verified: true, username: 'marta' } session.session = { verified: true, username: 'marta' }
session.status = 'ready' session.status = 'ready'
global.fetch = fetchRouter({ global.fetch = fetchRouter({
'GET /v1/article/12': () => 'GET /v1/article/12?format=html': () =>
mockResponse( mockResponse(
200, 200,
articleFixture({ articleFixture({
@@ -624,7 +624,7 @@ function requestCalls(wrapper: Awaited<ReturnType<typeof mount>>) {
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => { describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
it('opens ConfirmDialog with the exact copy and sends nothing', async () => { it('opens ConfirmDialog with the exact copy and sends nothing', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
}) })
await requestRemoveImage(wrapper) await requestRemoveImage(wrapper)
@@ -637,13 +637,13 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
expect(props.confirmLabel).toBe('Remove image') expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel') expect(props.cancelLabel).toBe('Cancel')
expect(props.tone).toBe('neutral') expect(props.tone).toBe('neutral')
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"') expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
}) })
it('previews the image the session has seen with its filename and size', async () => { it('previews the image the session has seen with its filename and size', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
}) })
useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }]) useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }])
@@ -659,7 +659,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
it('previews only the thumbnail for an image the session has not seen', async () => { it('previews only the thumbnail for an image the session has not seen', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
}) })
await requestRemoveImage(wrapper) await requestRemoveImage(wrapper)
@@ -676,7 +676,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
describe('Cancelling the confirm changes nothing', () => { describe('Cancelling the confirm changes nothing', () => {
it('keeps the image and the draft state when the dialog resolves to cancel', async () => { it('keeps the image and the draft state when the dialog resolves to cancel', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
}) })
await requestRemoveImage(wrapper) await requestRemoveImage(wrapper)
@@ -686,14 +686,14 @@ describe('Cancelling the confirm changes nothing', () => {
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"') expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes') expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled() expect(toastAdd).not.toHaveBeenCalled()
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
}) })
}) })
describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => { describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => {
it('removes the image from the content, reads Unsaved changes and sends no request', async () => { it('removes the image from the content, reads Unsaved changes and sends no request', async () => {
const wrapper = await mountEditor({ const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
}) })
await requestRemoveImage(wrapper) await requestRemoveImage(wrapper)
@@ -710,7 +710,7 @@ describe('Confirming removes the image from the body only, marks the draft dirty
title: 'Image removed', title: 'Image removed',
description: "It's still in your library.", description: "It's still in your library.",
}) })
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
}) })
}) })
@@ -718,7 +718,7 @@ describe('An unknown or deleted article id replaces the editor with an error sta
it('shows the not-found error state and navigates to /article', async () => { it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor( const wrapper = await mountEditor(
{ {
'GET /v1/article/9999': () => mockResponse(404), 'GET /v1/article/9999?format=html': () => mockResponse(404),
}, },
'/article/9999/edit', '/article/9999/edit',
) )
@@ -35,7 +35,7 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
session.session = { verified: true, username: 'marta', role } session.session = { verified: true, username: 'marta', role }
session.status = 'ready' session.status = 'ready'
global.fetch = vi.fn((url: string) => { global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/') { if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, [])) return Promise.resolve(mockResponse(200, []))
} }
if (url === '/v1/system/update') { if (url === '/v1/system/update') {
@@ -35,7 +35,7 @@ async function mountHome(role: UserRole, status: UpdateStatus) {
session.session = { verified: true, username: 'marta', role } session.session = { verified: true, username: 'marta', role }
session.status = 'ready' session.status = 'ready'
global.fetch = vi.fn((url: string) => { global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/') { if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, [])) return Promise.resolve(mockResponse(200, []))
} }
if (url === '/v1/system/update') { if (url === '/v1/system/update') {
+21 -21
View File
@@ -136,7 +136,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
vi.useFakeTimers() vi.useFakeTimers()
let resolveList: (() => void) | null = null let resolveList: (() => void) | null = null
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': () => 'GET /v1/article/?format=html': () =>
new Promise((resolve) => { new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticles)) resolveList = () => resolve(mockResponse(200, sixArticles))
}), }),
@@ -160,7 +160,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => { it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers() vi.useFakeTimers()
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)), 'GET /v1/article/?format=html': () => Promise.resolve(mockResponse(200, sixArticles)),
}) })
await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(0)
@@ -174,7 +174,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
describe('Default — the list loaded with a mix of online and draft articles', () => { describe('Default — the list loaded with a mix of online and draft articles', () => {
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => { it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) })
expect(wrapper.text()).toContain("Here's where things stand on your site.") expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -217,8 +217,8 @@ describe('A successful create hands off to the editor with a toast', () => {
updated_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
} }
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
'POST /v1/article/': () => mockResponse(201, created), 'POST /v1/article/?format=html': () => mockResponse(201, created),
}) })
await findButton(wrapper, 'New article')?.trigger('click') await findButton(wrapper, 'New article')?.trigger('click')
@@ -258,7 +258,7 @@ describe('A successful create hands off to the editor with a toast', () => {
describe('Empty, first run — zero articles', () => { describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => { it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []), 'GET /v1/article/?format=html': () => mockResponse(200, []),
}) })
expect(wrapper.text()).toContain('Welcome to RenewCMS.') expect(wrapper.text()).toContain('Welcome to RenewCMS.')
@@ -284,7 +284,7 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
}, },
])('shows the alert with identical copy for $name', async ({ handler }) => { ])('shows the alert with identical copy for $name', async ({ handler }) => {
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': handler, 'GET /v1/article/?format=html': handler,
}) })
expect(wrapper.text()).toContain("Here's where things stand on your site.") expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -304,7 +304,7 @@ describe('Retry re-issues the request and recovers into the Default state', () =
.mockResolvedValueOnce(mockResponse(500)) .mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(mockResponse(200, sixArticles)) .mockResolvedValueOnce(mockResponse(200, sixArticles))
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': listCall, 'GET /v1/article/?format=html': listCall,
}) })
expect(wrapper.find('[role="alert"]').exists()).toBe(true) expect(wrapper.find('[role="alert"]').exists()).toBe(true)
@@ -323,7 +323,7 @@ describe('The list is fetched exactly once per navigation', () => {
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => { it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
const listCall = vi.fn(() => mockResponse(200, sixArticles)) const listCall = vi.fn(() => mockResponse(200, sixArticles))
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': listCall, 'GET /v1/article/?format=html': listCall,
}) })
expect(listCall).toHaveBeenCalledTimes(1) expect(listCall).toHaveBeenCalledTimes(1)
@@ -344,7 +344,7 @@ describe('The greeting matches the hour of day', () => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0)) vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) })
expect(wrapper.text()).toContain(`${greeting}, marta`) expect(wrapper.text()).toContain(`${greeting}, marta`)
@@ -357,23 +357,23 @@ describe('The greeting is identical across every load state at a fixed hour', ()
vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0))
const loadingWrapper = await mountHome({ const loadingWrapper = await mountHome({
'GET /v1/article/': () => new Promise(() => {}), 'GET /v1/article/?format=html': () => new Promise(() => {}),
}) })
await vi.advanceTimersByTimeAsync(150) await vi.advanceTimersByTimeAsync(150)
expect(loadingWrapper.text()).toContain('Good morning, marta') expect(loadingWrapper.text()).toContain('Good morning, marta')
const defaultWrapper = await mountHome({ const defaultWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) })
expect(defaultWrapper.text()).toContain('Good morning, marta') expect(defaultWrapper.text()).toContain('Good morning, marta')
const emptyWrapper = await mountHome({ const emptyWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []), 'GET /v1/article/?format=html': () => mockResponse(200, []),
}) })
expect(emptyWrapper.text()).toContain('Good morning, marta') expect(emptyWrapper.text()).toContain('Good morning, marta')
const errorWrapper = await mountHome({ const errorWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(500), 'GET /v1/article/?format=html': () => mockResponse(500),
}) })
expect(errorWrapper.text()).toContain('Good morning, marta') expect(errorWrapper.text()).toContain('Good morning, marta')
}) })
@@ -394,7 +394,7 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName } session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready' session.status = 'ready'
global.fetch = fetchRouter({ global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch }) as unknown as typeof fetch
await router.push('/home') await router.push('/home')
const wrapper = mount(HomeView, { const wrapper = mount(HomeView, {
@@ -414,7 +414,7 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName: '' } session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready' session.status = 'ready'
global.fetch = fetchRouter({ global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch }) as unknown as typeof fetch
await router.push('/home') await router.push('/home')
const wrapper = mount(HomeView, { const wrapper = mount(HomeView, {
@@ -440,7 +440,7 @@ describe('The greeting handles a missing username', () => {
session.session = { verified: true } session.session = { verified: true }
session.status = 'ready' session.status = 'ready'
global.fetch = fetchRouter({ global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch }) as unknown as typeof fetch
await router.push('/home') await router.push('/home')
const wrapper = mount(HomeView, { const wrapper = mount(HomeView, {
@@ -461,7 +461,7 @@ describe('The "Connect your website" card renders in every state', () => {
it('renders the same card while loading, in Default, Empty and Error', async () => { it('renders the same card while loading, in Default, Empty and Error', async () => {
vi.useFakeTimers() vi.useFakeTimers()
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': () => new Promise(() => {}), 'GET /v1/article/?format=html': () => new Promise(() => {}),
}) })
await vi.advanceTimersByTimeAsync(150) await vi.advanceTimersByTimeAsync(150)
expect(wrapper.text()).toContain('Connect your website') expect(wrapper.text()).toContain('Connect your website')
@@ -471,17 +471,17 @@ describe('The "Connect your website" card renders in every state', () => {
vi.useRealTimers() vi.useRealTimers()
const defaultWrapper = await mountHome({ const defaultWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles), 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) })
expect(defaultWrapper.text()).toContain('Connect your website') expect(defaultWrapper.text()).toContain('Connect your website')
const emptyWrapper = await mountHome({ const emptyWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []), 'GET /v1/article/?format=html': () => mockResponse(200, []),
}) })
expect(emptyWrapper.text()).toContain('Connect your website') expect(emptyWrapper.text()).toContain('Connect your website')
const errorWrapper = await mountHome({ const errorWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(500), 'GET /v1/article/?format=html': () => mockResponse(500),
}) })
expect(errorWrapper.text()).toContain('Connect your website') expect(errorWrapper.text()).toContain('Connect your website')
}) })
@@ -71,12 +71,12 @@ describe("Default — the page loads with the app's own origin", () => {
expect(cardTexts).toHaveLength(2) expect(cardTexts).toHaveLength(2)
expect(cardTexts[0]).toContain('/v1/article/') expect(cardTexts[0]).toContain('/v1/article/')
expect(cardTexts[0]).toContain('Lists every online article.') expect(cardTexts[0]).toContain('Lists every online article.')
expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/`) expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/?format=html`)
expect(cardTexts[1]).toContain('/v1/article/{id}') expect(cardTexts[1]).toContain('/v1/article/{id}')
expect(cardTexts[1]).toContain( expect(cardTexts[1]).toContain(
'Returns one online article. Drafts and unknown ids both return 404.', 'Returns one online article. Drafts and unknown ids both return 404.',
) )
expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12`) expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12?format=html`)
const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch') const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch')
expect(fetchTabs.length).toBeGreaterThan(0) expect(fetchTabs.length).toBeGreaterThan(0)
@@ -138,7 +138,7 @@ describe("Each EndpointCard's own Copy button is independent of the Base URL's",
await flushPromises() await flushPromises()
const expectedCode = const expectedCode =
`const res = await fetch('${STUB_ORIGIN}/v1/article/')\n` + `const res = await fetch('${STUB_ORIGIN}/v1/article/?format=html')\n` +
'const articles = await res.json()' 'const articles = await res.json()'
expect(writeText).toHaveBeenCalledWith(expectedCode) expect(writeText).toHaveBeenCalledWith(expectedCode)
@@ -178,7 +178,7 @@ describe('curl and Response tabs render, unchecked visually', () => {
const firstCard = wrapper.findAllComponents(EndpointCard)[0] const firstCard = wrapper.findAllComponents(EndpointCard)[0]
await activateTab(firstCard.element as HTMLElement, 'curl') await activateTab(firstCard.element as HTMLElement, 'curl')
expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/`) expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/?format=html`)
await activateTab(firstCard.element as HTMLElement, 'Response') await activateTab(firstCard.element as HTMLElement, 'Response')
@@ -299,7 +299,7 @@ describe('Settings, owner, Restart buttons', () => {
it('ready: Restart is the enabled primary button', async () => { it('ready: Restart is the enabled primary button', async () => {
await mountSettings({ role: 'owner', status: ready() }) await mountSettings({ role: 'owner', status: ready() })
expect(cardButtons()).toEqual(["What's new in 2.3.0-beta", 'Restart']) expect(cardButtons()).toEqual(['Restart', "What's new in 2.3.0-beta"])
const restart = findButton('Restart') as HTMLButtonElement const restart = findButton('Restart') as HTMLButtonElement
expect(restart.disabled).toBe(false) expect(restart.disabled).toBe(false)
expect(restart.className).not.toContain('border-accented') expect(restart.className).not.toContain('border-accented')
@@ -311,6 +311,16 @@ describe('Settings, owner, Ready to install', () => {
expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false') expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false')
const restart = findButton('Restart') as HTMLButtonElement const restart = findButton('Restart') as HTMLButtonElement
expect(restart.disabled).toBe(false) expect(restart.disabled).toBe(false)
const tag = updatesCard().querySelector('[data-testid="update-tag"]')
expect(tag?.textContent?.trim()).toBe('Pending')
expect(updatesCard().querySelector('svg.text-warning')).not.toBeNull()
expect(updatesCard().querySelector('svg.text-success')).toBeNull()
const block = updatesCard().querySelector('[aria-live]') as HTMLElement
expect(block.className).toContain('--rc-warning-soft')
expect(block.contains(restart)).toBe(true)
expect(restart.className).toContain('h-11')
expect(restart.className).toContain('md:h-8')
expect(updatesCard().querySelector('[class*="md:flex-row"]')).toBeNull()
const readsBefore = callsTo(STATUS_PATH).length const readsBefore = callsTo(STATUS_PATH).length
await tick(3000) await tick(3000)
+7
View File
@@ -938,6 +938,12 @@ async function openAccountMenu(page: Page) {
await page.locator('[data-slot="footer"] button[aria-haspopup]').click() await page.locator('[data-slot="footer"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor() await page.locator('[role="menu"]').waitFor()
} }
await page.getByRole('menuitemradio', { name: 'Light', exact: true }).click()
await page.evaluate(() => {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.classList.toggle('dark', dark)
document.documentElement.classList.toggle('light', !dark)
})
await page.mouse.move(0, 0) await page.mouse.move(0, 0)
} }
@@ -1978,6 +1984,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
}, },
steps: openAccountMenu, steps: openAccountMenu,
}, },