Compare commits

..
164 changed files with 7907 additions and 1504 deletions

No files matched your search

+6 -4
View File
@@ -10,6 +10,7 @@ import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"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 {
hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
return &UseCases{
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
+102 -24
View File
@@ -12,6 +12,7 @@ import (
"log"
"net/http"
"strconv"
"time"
"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) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
@@ -53,11 +101,16 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var article domain.Article
if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id)
article, err = h.GetUseCase.GetArticle(id, format)
} else {
article, err = h.GetUseCase.GetOnlineArticle(id)
article, err = h.GetUseCase.GetOnlineArticle(id, format)
}
if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
@@ -68,7 +121,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
articleJson, err := json.Marshal(article)
articleJson, err := encodeArticle(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
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) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle PostArticle
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -92,8 +150,9 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
}
createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{
Title: localArticle.Title,
Body: localArticle.Body,
Title: localArticle.Title,
Body: localArticle.Body,
Format: format,
})
if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) {
@@ -104,16 +163,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return
}
articleJson, err := json.Marshal(createdArticle)
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)
}
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
}
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)
}
func writeArticleJsonResponse(w http.ResponseWriter, article domain.Article) {
articleJson, err := json.Marshal(article)
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(articleJson); err != nil {
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
}
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 {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, updatedArticle)
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
}
type UpdateArticleBody struct {
@@ -163,6 +221,11 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle UpdateArticleBody
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -174,13 +237,13 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return
}
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body)
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body, format)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, updatedArticle)
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
}
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) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var articles []domain.Article
var err error
if h.callerIsVerified(r) {
articles, err = h.ListUseCase.ListAllArticles()
articles, err = h.ListUseCase.ListAllArticles(format)
} else {
articles, err = h.ListUseCase.ListOnlineArticles()
articles, err = h.ListUseCase.ListOnlineArticles(format)
}
if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
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 {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
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 UpdateRefusedErrorMessage = "This update cannot be downloaded."
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
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.
@@ -55,7 +55,7 @@ default slot
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.
@@ -383,12 +383,13 @@ The Product components board was not regenerated for these. They are written fro
### 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.
- **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`.
- **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.
- **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 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.
- 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.
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
@@ -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 |
|---|---|---|---|---|
| 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 |
| ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard |
| 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) |
| Item | Profile (circle-user icon), highlighted while on `/profile` |
| 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 |
| 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) |
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")
- 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
+8 -6
View File
@@ -20,7 +20,9 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
| 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. |
| 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. |
@@ -28,7 +30,7 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
| Hint | 12 px muted line under the buttons. |
| 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
@@ -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." |
| 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." |
| 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." |
| 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" |
@@ -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. |
| 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". |
| Ready to install | The file passed its checks. Restart becomes the primary action. "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: request failed | 500 or network failure before the restart began. The site is still running. |
| 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. 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. 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, 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. |
+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="{{ 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>
<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>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
<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 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>
<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.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>
@@ -280,7 +280,7 @@
</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>
@@ -435,7 +435,7 @@ class Component extends DCLogic {
const edTitle = e.title || 'How we price a bathroom renovation'
return {
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)',
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,
+6 -3
View File
@@ -54,16 +54,17 @@
<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)">
<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>
</sc-if>
</div>
</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 {
renderVals() {
const s = this.props.s || {}
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 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
@@ -87,7 +88,9 @@ class Component extends DCLogic {
return {
s, notice, result, fields,
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',
w: m ? '100%' : '400px',
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">
<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)">
<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="{{ 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 }}">
@@ -187,6 +187,9 @@ class Component extends DCLogic {
act(a) {
const s = this.state
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 (s.userMenu) this.setState({ userMenu: false })
if (a === 'pf-reset') return this.setState({ pfReset: 'sent' })
@@ -428,8 +431,8 @@ class Component extends DCLogic {
rootRef: this.rootRef, stageRef: this.stageRef,
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 }),
mobile, isAuth, isApp, isEmail,
authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode } : {},
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, lang: s.lang || 'en' } : {},
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',
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 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 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 SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) })
const LAST = 'Last checked today at 09:41'
+2
View File
@@ -28,3 +28,5 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.4.0-beta` | v2.4.0-beta - Owner role, invite management & Settings page |
| `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.2-beta` | v2.6.2-beta - English and French interface |
| `v2.7.0-beta` | v2.7.0-beta - Markdown and block article bodies |
+29
View File
@@ -0,0 +1,29 @@
This release adds English and French throughout the SPA, with an instant language switch and a preference remembered on this browser.
## New
**English and French interface**
- Choose English or Français from the Language control in the desktop or mobile profile submenu.
- Switch interface language immediately while keeping your current page and unsaved work.
- Keep your selected language after navigation, reload and logout, including on authentication screens.
- See translated forms, validation, notifications, dialogs and accessibility labels, with localized dates, numbers and counts.
## Improvements and fixes
- Keep partial bulk-delete failure notifications available after retrying, including when switching languages.
## Under the hood
- Add translation catalog parity, fallback, persistence and SPA interaction tests.
## Upgrading
- Install the signed update through Settings on a prepared Linux/systemd installation, or replace the binary and restart the service.
- No database migration or new environment variable is required.
- English remains the default until a language is selected; the preference is stored locally in the browser.
- User content, API data and transactional emails retain their existing language.
## Known limitations
- Visual parity checks for this feature were skipped under the accepted slice waiver.
+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.
+75
View File
@@ -0,0 +1,75 @@
Feature: English and French throughout the SPA
Background:
Given all existing SPA interface text supports English and French
And English preserves existing copy and French provides equivalent meaning
And user content, API data and transactional emails remain unchanged
And the language control follows features/language_switcher_conformance.feature
Scenario Outline: Resolve the language before rendering
Given the browser-local stored language is <stored>
When the SPA starts
Then the interface language and document lang are <locale>
And browser language does not override the choice
Examples:
| stored | locale |
| absent | en |
| en | en |
| fr | fr |
| unsupported | en |
| unreadable | en |
Scenario Outline: Offer the language choice in the account menu
Given the user opens the <menu>
Then a localized Language section offers English and Français as radio items
And the selected language is visibly and accessibly identified
Examples:
| menu |
| desktop UserMenu |
| mobile AccountMenuSheet |
Scenario: Switch language without losing work
Given the user is editing an unsaved display name on /profile
When they select Français
Then all SPA interface text immediately uses French
And the document lang is fr
And the route, session, theme and unsaved work remain unchanged
And no reload, navigation or API request is caused by switching
When they select English
Then the interface immediately restores English with the same preserved state
Scenario: Remember language on this browser
Given the user selected Français
When they navigate, reload, log out or return on this browser
Then French remains selected including on public authentication screens
And no language preference is sent to the server
Scenario: Storage failure does not prevent switching
Given preference storage cannot be written
When the user selects Français
Then French is used for the current session without an error
And a fresh load without a readable preference defaults to English
Scenario: Translate the entire interface
Given French is selected
When an existing SPA screen or shared component renders
Then navigation, forms, validation, notifications, dialogs and accessibility labels use French
And counts, human-readable dates and numbers use the selected locale
And Nuxt UI controls use the selected locale
And English and French catalogs have matching keys and interpolation parameters
And an unavailable French message falls back to English
And user content, account details, filenames, URLs, API data and emails remain unchanged
Scenario Outline: Operate the language choice accessibly
Given the <menu> is open
When the user navigates the language items with the keyboard and confirms a choice
Then the language changes and the menu stays open
When they press Escape or click outside
Then the menu closes without changing language
Examples:
| menu |
| desktop UserMenu |
| mobile AccountMenuSheet |
+19
View File
@@ -0,0 +1,19 @@
# SPA internationalization QA
Accepted by the user on 2026-10-03 together with the slice specification.
## Automated
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 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.
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.
6. Audit every existing view, shared component, composable and library producing interface text, including editor, media, users, invitations, settings and updates. Verify Nuxt UI controls use the active locale.
7. Assert catalog key/interpolation parity, nonempty translations, pluralization, English fallback and locale-sensitive formatting. Use fixed input dates without freezing system time. Verify displayed alerts and dialogs react to language changes.
8. Verify user content, account details, filenames, URLs, API fields, integration protocol examples, server release notes and transactional emails remain unchanged.
9. Run task test:web, frontend type checks, applicable lint and final build checks. Inspect introduced comments and ensure application changes are SPA-only.
## Visual and manual
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.
+1 -1
View File
@@ -254,7 +254,7 @@ Feature: Updates part 2: the Update button (download, verify, stage)
Scenario: Settings, owner, Ready to install
Given the poll answers state "ready"
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." /
"Nothing has changed yet."
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/google/uuid v1.6.0
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/net v0.59.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
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/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
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/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
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/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
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 {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
}
type CreateArticleCommand struct {
Title string
Body string
Title string
Body string
Format article.BodyFormat
}
func NewCreateArticleUseCase(articleRepository article.Repository) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository}
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository, bodyRenderer}
}
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
@@ -22,9 +24,13 @@ func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand)
return article.Article{}, err
}
return g.articleRepository.Create(article.Article{
created, err := g.articleRepository.Create(article.Article{
Title: createArticle.Title,
Body: createArticle.Body,
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 {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
}
func NewGetArticleUseCase(articleRepository article.Repository) *GetArticleUseCase {
return &GetArticleUseCase{articleRepository}
func NewGetArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *GetArticleUseCase {
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)
if err != nil {
return article.Article{}, err
@@ -20,11 +21,15 @@ func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error)
return article.Article{}, article.ErrArticleNotFound
}
return art, nil
return renderArticle(u.bodyRenderer, art, format)
}
func (u *GetArticleUseCase) GetArticle(id uint32) (article.Article, error) {
return u.articleRepository.Get(id)
func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat) (article.Article, error) {
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) {
@@ -4,19 +4,28 @@ import "RenewCMS/internal/domain/article"
type ListArticlesUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
}
func NewListArticlesUseCase(articleRepository article.Repository) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository}
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository, bodyRenderer}
}
func (u *ListArticlesUseCase) ListOnlineArticles() ([]article.Article, error) {
func (u *ListArticlesUseCase) ListOnlineArticles(format article.BodyFormat) ([]article.Article, error) {
online := true
return u.articleRepository.FindByFilters(article.Filters{
articles, err := u.articleRepository.FindByFilters(article.Filters{
IsOnline: &online,
})
if err != nil {
return nil, err
}
return renderArticles(u.bodyRenderer, articles, format)
}
func (u *ListArticlesUseCase) ListAllArticles() ([]article.Article, error) {
return u.articleRepository.FindByFilters(article.Filters{})
func (u *ListArticlesUseCase) ListAllArticles(format article.BodyFormat) ([]article.Article, error) {
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 {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
}
func NewUpdateArticleUseCase(articleRepository article.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository, bodyRenderer}
}
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Article, error) {
return g.articleRepository.UpdateBody(id, body)
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string, format article.BodyFormat) (article.Article, error) {
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) {
return g.articleRepository.UpdateIsOnline(id, isOnline)
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
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)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+4 -4
View File
@@ -148,7 +148,7 @@ var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
@@ -174,17 +174,17 @@ func TestGetOnlineArticle(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, IsOnline: true}
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) {
_, err := uc.GetOnlineArticle(1)
_, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil {
t.Errorf("Expected article to be found")
}
})
t.Run("Article Offline", func(t *testing.T) {
_, err := uc.GetOnlineArticle(2)
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
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)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo),
useCases.NewGetArticleUseCase(repo),
useCases.NewListArticlesUseCase(repo),
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}),
getUserUseCase,
)
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
title := strings.Repeat("\U0001F600", tc.runeCount)
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)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
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)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
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{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
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 {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
hasher := hashing.NewBcryptHasher()
+4 -4
View File
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+4 -4
View File
@@ -24,11 +24,11 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+4 -4
View File
@@ -132,10 +132,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
return &api.UseCases{
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
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 {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
loginHandler := auth.NewLoginHandler(
+30 -30
View File
@@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
title := strings.Repeat("a", tc.length)
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) {
repo := newMockArticleRepo()
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"})
@@ -84,8 +84,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticle(1)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(1, article.BodyFormatHTML)
if err != nil {
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) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticle(2)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(2, article.BodyFormatHTML)
if err != nil {
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) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetArticle(9)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticle(9, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
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) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetOnlineArticle(1)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil {
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) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetOnlineArticle(2)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
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) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetOnlineArticle(9)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(9, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
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) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticleByName("Backyard Renovation Update")
if err != nil {
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) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticleByName("Unknown Title")
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -198,8 +198,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListOnlineArticles", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListOnlineArticles()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -208,8 +208,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListAllArticles", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListAllArticles()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListAllArticles(article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -219,9 +219,9 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo)
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles()
result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
if err != nil {
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) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
result, err := uc.UpdateBody(1, "New body")
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateBody(1, "New body", article.BodyFormatHTML)
if err != nil {
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) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
_, err := uc.UpdateBody(9, "New body")
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateBody(9, "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
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) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
result, err := uc.UpdateIsOnline(1, true)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateIsOnline(1, true, article.BodyFormatHTML)
if err != nil {
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) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
_, err := uc.UpdateIsOnline(9, true)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateIsOnline(9, true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
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 {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+4 -4
View File
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+3
View File
@@ -10,6 +10,7 @@
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@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-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/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/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
+3 -1
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { en, fr } from '@nuxt/ui/locale'
import { locale } from '@/i18n'
import { RouterView } from 'vue-router'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
@@ -11,7 +13,7 @@ const toaster = computed(() => ({
</script>
<template>
<UApp class="isolate" :toaster="toaster">
<UApp class="isolate" :toaster="toaster" :locale="locale === 'fr' ? fr : en">
<RouterView />
</UApp>
</template>
+66 -27
View File
@@ -1,10 +1,15 @@
<script setup lang="ts">
import { ref } from 'vue'
import { locale, t } from '@/i18n'
import { ref, useId } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import { chooseLanguage, languageOptions } from '@/composables/useLanguage'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"]'
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<{
username: string
@@ -26,6 +31,8 @@ const {
goToProfile: navigateToProfile,
} = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null)
const themeLabelId = useId()
const languageLabelId = useId()
function goToProfile() {
open.value = false
@@ -34,7 +41,6 @@ function goToProfile() {
function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value
open.value = false
}
function logOut() {
@@ -68,8 +74,8 @@ function moveFocus(event: KeyboardEvent) {
<USlideover
v-model:open="open"
side="bottom"
title="Account"
description="Your account menu"
:title="t('Account')"
:description="t('Your account menu')"
:close="false"
:ui="{
content: 'max-h-none',
@@ -78,7 +84,13 @@ function moveFocus(event: KeyboardEvent) {
}"
>
<template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
<div
ref="menu"
role="menu"
:aria-label="t('Account')"
class="pt-2 pb-6"
@keydown="moveFocus"
>
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar
@@ -86,7 +98,7 @@ function moveFocus(event: KeyboardEvent) {
:text="initials"
:ui="{
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">
@@ -107,28 +119,55 @@ function moveFocus(event: KeyboardEvent) {
:ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile"
>
Profile
{{ t('Profile') }}
</UButton>
<USeparator class="my-1" />
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemcheckbox"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</UButton>
<div role="group" :aria-labelledby="themeLabelId">
<div :id="themeLabelId" :class="SECTION_LABEL_CLASS">{{ t('Theme') }}</div>
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemradio"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</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" />
@@ -142,7 +181,7 @@ function moveFocus(event: KeyboardEvent) {
:ui="{ leadingIcon: 'size-4.5' }"
@click="logOut"
>
Log out
{{ t('Log out') }}
</UButton>
</div>
</template>
+4 -2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<div class="space-y-4 p-6" role="status" aria-label="Loading">
<div class="space-y-4 p-6" role="status" :aria-label="t('Loading')">
<USkeleton class="h-6 w-40" />
<USkeleton class="h-32 w-full" />
<USkeleton class="h-32 w-full" />
+17 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate'
@@ -39,12 +41,16 @@ function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
@@ -56,14 +62,18 @@ function actionsFor(article: Article) {
},
},
{
label: 'Copy API URL',
get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
@@ -122,7 +132,8 @@ function onRowClick(article: Article, event: MouseEvent) {
:pending="pendingFor(article)"
/>
<span :title="article.updated_at"
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span
>{{ t('Updated') }}
{{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
</div>
@@ -135,7 +146,7 @@ function onRowClick(article: Article, event: MouseEvent) {
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${article.title}`"
:aria-label="t('Actions for {0}', [article.title])"
class="size-11 justify-center text-toned"
@click.stop
/>
+50 -31
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { t, locale, formatNumber } from '@/i18n'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { computed } from 'vue'
import { useToast } from '@nuxt/ui/composables'
import { useLocalizedToast } from '@/i18n/presentation'
import type { Article } from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
@@ -8,7 +11,7 @@ const props = defineProps<{ article: Article }>()
const emit = defineEmits<{ 'copy-url': [] }>()
const toast = useToast()
const toast = useLocalizedToast()
const open = defineModel<boolean>('open', { default: false })
@@ -43,9 +46,11 @@ function formatDetailDate(iso: string, relative = false) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
if (relative && isSameDay(date, new Date())) {
return `Today, ${hours}:${minutes}`
return t('Today, {0}:{1}', [hours, minutes])
}
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
return locale.value === 'fr'
? `${formatAbsoluteDateFromIso(iso)}, ${hours}:${minutes}`
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
}
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
@@ -54,29 +59,37 @@ const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => bodyImageCount(props.article.body))
const apiNote = computed(() =>
props.article.is_online
? 'Live. Your website can read this article.'
: 'Returns 404 until the article is online.',
? t('Live. Your website can read this article.')
: t('Returns 404 until the article is online.'),
)
async function copyUrl() {
const url = `${window.location.origin}${publicUrl.value}`
try {
await navigator.clipboard.writeText(url)
toast.add({
toast.add(() => ({
id: `article-link-copied-${props.article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description: `The API URL for "${props.article.title}" is on your clipboard.`,
})
get title() {
return t('Link copied')
},
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
props.article.title,
])
},
}))
emit('copy-url')
} catch {
toast.add({
toast.add(() => ({
id: `article-link-copy-failed-${props.article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
get title() {
return t("Couldn't copy the link")
},
}))
}
}
</script>
@@ -93,20 +106,24 @@ async function copyUrl() {
}"
>
<template #header>
<h2 class="text-base font-semibold text-highlighted">Details</h2>
<h2 class="text-base font-semibold text-highlighted">
{{ t('Details') }}
</h2>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">Created</dt>
<dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dt class="text-muted">{{ t('Updated') }}</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">{{ article.id }}</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
<dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl>
<div class="flex flex-col gap-1.5 text-sm">
<p class="text-muted">Public API URL</p>
<p class="text-muted">{{ t('Public API URL') }}</p>
<div class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs">
<span class="flex-1">{{ publicUrl }}</span>
<UButton
@@ -114,7 +131,7 @@ async function copyUrl() {
color="neutral"
variant="ghost"
size="sm"
aria-label="Copy the public API URL"
:aria-label="t('Copy the public API URL')"
class="size-5 p-0"
@click="copyUrl"
/>
@@ -122,28 +139,30 @@ async function copyUrl() {
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
<p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned">{{ t(TITLE_NOTE) }}</p>
</div>
</UCard>
<USlideover v-model:open="open" side="right" title="Details" class="lg:hidden">
<USlideover v-model:open="open" side="right" :title="t('Details')" class="lg:hidden">
<template #body>
<div class="flex flex-col gap-4 text-sm">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt class="text-muted">Created</dt>
<dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dt class="text-muted">{{ t('Updated') }}</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">
{{ article.id }}
</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
<dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl>
<div class="flex flex-col gap-1.5">
<p class="text-muted">Public API URL</p>
<p class="text-muted">{{ t('Public API URL') }}</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
@@ -153,7 +172,7 @@ async function copyUrl() {
color="neutral"
variant="ghost"
size="sm"
aria-label="Copy the public API URL"
:aria-label="t('Copy the public API URL')"
class="size-5 p-0"
@click="copyUrl"
/>
@@ -161,9 +180,9 @@ async function copyUrl() {
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div class="flex flex-col gap-1 border-t border-default pt-3">
<p class="text-muted">Title</p>
<p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned">
{{ TITLE_NOTE }}
{{ t(TITLE_NOTE) }}
</p>
</div>
</div>
+129 -29
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleTextAlign } from '@/lib/articleAlign'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
const props = withDefaults(
@@ -25,6 +28,7 @@ const emit = defineEmits<{
const editorInstance = ref<Editor | null>(null)
const extensions = [
ArticleIndent,
ArticleTextAlign,
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
@@ -42,10 +46,10 @@ function onUpdate(value: string) {
function currentBlockLabel(editor: Editor) {
for (let level = 1; level <= 4; level += 1) {
if (editor.isActive('heading', { level })) {
return `Heading ${level}`
return t('Heading {0}', [level])
}
}
return 'Paragraph'
return t('Paragraph')
}
function isDefaultAlignment(editor: Editor) {
@@ -58,12 +62,20 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{
kind: 'undo',
icon: 'i-lucide-undo-2',
tooltip: { text: 'Undo' },
tooltip: {
get text() {
return t('Undo')
},
},
},
{
kind: 'redo',
icon: 'i-lucide-redo-2',
tooltip: { text: 'Redo' },
tooltip: {
get text() {
return t('Redo')
},
},
},
],
[
@@ -72,40 +84,70 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
label: currentBlockLabel(editor),
trailingIcon: 'i-lucide-chevron-down',
ui: { trailingIcon: 'size-3' },
tooltip: { text: 'Paragraph style' },
tooltip: {
get text() {
return t('Paragraph style')
},
},
items: [
{
icon: 'i-lucide-pilcrow',
label: 'Paragraph',
get label() {
return t('Paragraph')
},
onSelect: () => editor.chain().focus().setParagraph().run(),
},
{
kind: 'heading',
level: 1,
icon: 'i-lucide-heading-1',
label: 'Heading 1',
tooltip: { text: 'Heading 1' },
get label() {
return t('Heading 1')
},
tooltip: {
get text() {
return t('Heading 1')
},
},
},
{
kind: 'heading',
level: 2,
icon: 'i-lucide-heading-2',
label: 'Heading 2',
tooltip: { text: 'Heading 2' },
get label() {
return t('Heading 2')
},
tooltip: {
get text() {
return t('Heading 2')
},
},
},
{
kind: 'heading',
level: 3,
icon: 'i-lucide-heading-3',
label: 'Heading 3',
tooltip: { text: 'Heading 3' },
get label() {
return t('Heading 3')
},
tooltip: {
get text() {
return t('Heading 3')
},
},
},
{
kind: 'heading',
level: 4,
icon: 'i-lucide-heading-4',
label: 'Heading 4',
tooltip: { text: 'Heading 4' },
get label() {
return t('Heading 4')
},
tooltip: {
get text() {
return t('Heading 4')
},
},
},
],
},
@@ -115,25 +157,41 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' },
tooltip: {
get text() {
return t('Bold')
},
},
},
{
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' },
tooltip: {
get text() {
return t('Italic')
},
},
},
{
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' },
tooltip: {
get text() {
return t('Underline')
},
},
},
{
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' },
tooltip: {
get text() {
return t('Strikethrough')
},
},
},
],
[
@@ -142,46 +200,78 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
align: 'left',
active: isDefaultAlignment(editor),
icon: 'i-lucide-align-left',
tooltip: { text: 'Align left' },
tooltip: {
get text() {
return t('Align left')
},
},
},
{
kind: 'textAlign',
align: 'center',
icon: 'i-lucide-align-center',
tooltip: { text: 'Align center' },
tooltip: {
get text() {
return t('Align center')
},
},
},
{
kind: 'textAlign',
align: 'right',
icon: 'i-lucide-align-right',
tooltip: { text: 'Align right' },
tooltip: {
get text() {
return t('Align right')
},
},
},
{
kind: 'textAlign',
align: 'justify',
icon: 'i-lucide-align-justify',
tooltip: { text: 'Justify' },
tooltip: {
get text() {
return t('Justify')
},
},
},
],
[
{
kind: 'bulletList',
icon: 'i-lucide-list',
tooltip: { text: 'Bullet list' },
tooltip: {
get text() {
return t('Bullet list')
},
},
},
{
kind: 'orderedList',
icon: 'i-lucide-list-ordered',
tooltip: { text: 'Numbered list' },
tooltip: {
get text() {
return t('Numbered list')
},
},
},
{
icon: 'i-lucide-indent-decrease',
tooltip: { text: 'Outdent' },
tooltip: {
get text() {
return t('Outdent')
},
},
onClick: () => editor.chain().focus().outdent().run(),
},
{
icon: 'i-lucide-indent-increase',
tooltip: { text: 'Indent' },
tooltip: {
get text() {
return t('Indent')
},
},
onClick: () => editor.chain().focus().indent().run(),
},
],
@@ -189,12 +279,22 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{
kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting',
tooltip: { text: 'Clear formatting' },
tooltip: {
get text() {
return t('Clear formatting')
},
},
},
{
icon: 'i-lucide-image-plus',
label: 'Image',
tooltip: { text: 'Add an image' },
get label() {
return t('Image')
},
tooltip: {
get text() {
return t('Add an image')
},
},
onClick: () => emit('request-image'),
},
],
+15 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
@@ -18,7 +20,14 @@ interface ImageNodeAttrs {
const imageAttrs = computed(() => {
const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
return {
src,
alt,
title,
width,
height,
'data-image-id': props.node.attrs['data-image-id'],
}
})
const failed = ref(false)
@@ -56,8 +65,10 @@ function onDeleteClick() {
class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted"
>
<UIcon name="i-lucide-image-off" class="size-6" />
<span class="font-medium text-highlighted">This image is no longer available</span>
<span>It was deleted from the library. Remove it from the article.</span>
<span class="font-medium text-highlighted">{{
t('This image is no longer available')
}}</span>
<span>{{ t('It was deleted from the library. Remove it from the article.') }}</span>
</div>
<img
v-else
@@ -77,7 +88,7 @@ function onDeleteClick() {
@mousedown.prevent
@click="onDeleteClick"
>
Remove image
{{ t('Remove image') }}
</UButton>
</NodeViewWrapper>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
withDefaults(
defineProps<{
online: boolean
@@ -21,10 +23,10 @@ withDefaults(
:class="size === 'lg' ? undefined : 'px-2'"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }}
{{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
</UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online
{{ t('Online') }}
</UBadge>
<UBadge
v-else
@@ -34,6 +36,6 @@ withDefaults(
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
>
Draft
{{ t('Draft') }}
</UBadge>
</template>
+54 -15
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
@@ -27,10 +29,37 @@ const columnWidths =
const sorting = ref([{ id: 'updated_at', desc: true }])
const columns = [
{ accessorKey: 'title', header: 'Title', enableSorting: true },
{ accessorKey: 'is_online', header: 'Status', size: 110, enableSorting: true },
{ accessorKey: 'created_at', header: 'Created', size: 130, enableSorting: true },
{ accessorKey: 'updated_at', header: 'Updated', size: 140, enableSorting: true },
{
accessorKey: 'title',
get header() {
return t('Title')
},
enableSorting: true,
},
{
accessorKey: 'is_online',
get header() {
return t('Status')
},
size: 110,
enableSorting: true,
},
{
accessorKey: 'created_at',
get header() {
return t('Created')
},
size: 130,
enableSorting: true,
},
{
accessorKey: 'updated_at',
get header() {
return t('Updated')
},
size: 140,
enableSorting: true,
},
{ id: 'actions', header: '', size: 44 },
]
@@ -64,12 +93,16 @@ function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
@@ -81,14 +114,18 @@ function actionsFor(article: Article) {
},
},
{
label: 'Copy API URL',
get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
@@ -104,7 +141,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">Loading articles</span>
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div
v-for="n in 5"
:key="n"
@@ -131,7 +168,9 @@ function onSelect(_event: Event, row: { original: Article }) {
>
<template #caption>
<caption class="sr-only">
Articles, sorted by last updated, newest first
{{
t('Articles, sorted by last updated, newest first')
}}
</caption>
</template>
@@ -141,7 +180,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Title
{{ t('Title') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -152,7 +191,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Status
{{ t('Status') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -163,7 +202,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Created
{{ t('Created') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -174,7 +213,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Updated
{{ t('Updated') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -217,7 +256,7 @@ function onSelect(_event: Event, row: { original: Article }) {
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${row.original.title}`"
:aria-label="t('Actions for {0}', [row.original.title])"
class="size-8 justify-center text-toned"
@click.stop
/>
+13 -6
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import LanguageChip from '@/components/LanguageChip.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
const props = defineProps<{
@@ -59,12 +62,13 @@ const footerClass = computed(() => [
cardClass.value,
])
const colorModeSelectUi = {
base: 'h-7 ps-7 pe-2 text-sm text-toned',
const colorModeSelectUi = computed(() => ({
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',
leadingIcon: 'size-3.5 text-toned',
trailing: 'hidden',
}
content: 'w-auto min-w-36',
}))
watch(
() => props.title,
@@ -72,7 +76,7 @@ watch(
if (!title) {
return
}
document.title = `${title} · RenewCMS`
document.title = t('{0} · RenewCMS', [title])
},
{ immediate: true },
)
@@ -116,11 +120,14 @@ watch(
/>
</UPageCard>
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
<footer :class="footerClass">
<span class="flex flex-wrap items-center gap-1.5">
<slot name="footer" />
</span>
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
<span class="flex items-center gap-2">
<LanguageChip :mobile="belowSm" />
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
</span>
</footer>
</main>
</template>
+22 -14
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaBadge from '@/components/MediaBadge.vue'
@@ -16,7 +18,7 @@ const props = defineProps<{
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const total = computed(() => props.items.length)
const imageLabel = computed(() => `${total.value} ${total.value === 1 ? 'image' : 'images'}`)
const imageLabel = computed(() => t(total.value === 1 ? '{0} image' : '{0} images', [total.value]))
const inUse = computed(() => props.items.filter((item) => item.used_in > 0))
const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
const description = computed(() => {
@@ -25,28 +27,34 @@ const description = computed(() => {
const articles = pluralArticles(articleCount.value)
if (usedCount === 0) {
return total.value === 1
? `It isn't used in any article. ${FINAL_WARNING}`
: `None of them are used in any article. ${FINAL_WARNING}`
? t("It isn't used in any article. {0}", [t(FINAL_WARNING)])
: t('None of them are used in any article. {0}', [t(FINAL_WARNING)])
}
if (unusedCount === 0) {
const subject = total.value === 1 ? "It's" : `All ${total.value} are`
return `${subject} used in ${articles}, ${BROKEN_IMAGE} ${FINAL_WARNING}`
const subject = total.value === 1 ? t("It's") : t('All {0} are', [total.value])
return t('{0} used in {1}, {2} {3}', [subject, articles, t(BROKEN_IMAGE), t(FINAL_WARNING)])
}
const rest =
unusedCount === 1
? "The other one isn't used anywhere."
: `The other ${unusedCount} aren't used anywhere.`
return `${usedCount} of them are used in ${articles}, ${BROKEN_IMAGE} ${rest} ${FINAL_WARNING}`
? t("The other one isn't used anywhere.")
: t("The other {0} aren't used anywhere.", [unusedCount])
return t('{0} of them are used in {1}, {2} {3} {4}', [
usedCount,
articles,
t(BROKEN_IMAGE),
rest,
t(FINAL_WARNING),
])
})
</script>
<template>
<ConfirmDialog
:title="`Delete ${imageLabel}?`"
:title="t('Delete {0}?', [imageLabel])"
:description="description"
:confirm-label="`Delete ${imageLabel}`"
cancel-label="Cancel"
pending-label="Deleting…"
:confirm-label="t('Delete {0}', [imageLabel])"
:cancel-label="t('Cancel')"
:pending-label="t('Deleting…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
@@ -66,9 +74,9 @@ const description = computed(() => {
<MediaBadge
v-if="item.used_in > 0"
kind="used"
:label="`Used in ${item.used_in}`"
:label="t('Used in {0}', [item.used_in])"
/>
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</li>
</ul>
</template>
+11 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, locale, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
const props = withDefaults(
@@ -19,6 +21,7 @@ const props = withDefaults(
)
function formatThousands(count: number) {
if (locale.value === 'fr') return formatNumber(count)
return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
@@ -38,11 +41,11 @@ const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
const overText = computed(() => {
const suffix = props.plainText
? 'Shorten the text.'
: 'Shorten the text or remove an image. Formatting counts too.'
? t('Shorten the text.')
: t('Shorten the text or remove an image. Formatting counts too.')
return overBy.value === 1
? `1 character over. ${suffix}`
: `${formatThousands(overBy.value)} characters over. ${suffix}`
? t('1 character over. {0}', [suffix])
: t('{0} characters over. {1}', [formatThousands(overBy.value), suffix])
})
const warnText = 'Close to the limit. Images and formatting count too.'
@@ -52,21 +55,21 @@ const message = computed(() => {
return overText.value
}
if (status.value === 'warn') {
return warnText
return t(warnText)
}
return ''
})
const announcement = ref('')
watch(status, (next, previous) => {
if (next === previous) {
watch([status, locale], ([next], [previous]) => {
if (next === previous && !announcement.value) {
return
}
if (next === 'over') {
announcement.value = overText.value
} else if (next === 'warn') {
announcement.value = 'Almost at the length limit.'
announcement.value = t('Almost at the length limit.')
} else {
announcement.value = ''
}
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watchEffect } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter'
@@ -11,7 +13,7 @@ const props = defineProps<{
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const highlightedHtml = ref<string | null>(null)
@@ -37,7 +39,7 @@ function onCopy() {
color="neutral"
variant="ghost"
size="xs"
:aria-label="`Copy ${label ?? lang} example`"
:aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite"
class="gap-1.5 px-1.5 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
+10 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -28,7 +30,9 @@ const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const previewDetail = computed(() =>
props.preview?.size ? `Stays in the library · ${props.preview.size}` : 'Stays in the library',
props.preview?.size
? t('Stays in the library · {0}', [props.preview.size])
: t('Stays in the library'),
)
const previewThumb = computed(() => ({
url: props.preview?.src ?? '',
@@ -111,11 +115,14 @@ function onCancel() {
v-if="error"
color="error"
icon="i-lucide-circle-x"
:description="error"
:description="translate(error)"
variant="soft"
:close="false"
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
:ui="{
root: 'gap-2 px-3 py-2.5',
description: 'text-sm text-toned',
}"
/>
<div
v-if="preview"
+51 -10
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
@@ -50,19 +52,56 @@ const api = useApi()
const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' },
{
get label() {
return t('Home')
},
to: '/home',
icon: 'i-lucide-home',
},
{
get label() {
return t('Articles')
},
to: '/article',
icon: 'i-lucide-file-text',
},
{
get label() {
return t('Media')
},
to: '/media',
icon: 'i-lucide-images',
},
{
get label() {
return t('Users')
},
to: '/users',
icon: 'i-lucide-users',
},
{
get label() {
return t('Integration')
},
to: '/integration',
icon: 'i-lucide-plug',
},
{
get label() {
return t('Settings')
},
to: '/settings',
icon: 'i-lucide-settings',
slot: 'settings',
},
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
)
const routeTitle = computed(() => (route.meta.title as string) ?? '')
const routeTitle = computed(() => translate((route.meta.title as string) ?? ''))
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
const sidebarCollapsed = useSidebarCollapsed()
@@ -130,7 +169,9 @@ async function onLogout() {
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<a href="#dashboard-content" class="sr-only focus:not-sr-only">{{
t('Skip to content')
}}</a>
<UDashboardSidebar
v-model:open="sidebarOpen"
@@ -155,7 +196,7 @@ async function onLogout() {
orientation="vertical"
:items="navItems"
:collapsed="sidebarCollapsed"
aria-label="Main"
:aria-label="t('Main')"
:ui="navUi"
>
<template #settings-trailing>
@@ -199,7 +240,7 @@ async function onLogout() {
<span
v-if="update.showCue"
role="img"
aria-label="Update available"
:aria-label="t('Update available')"
class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info"
/>
</span>
+14 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
@@ -15,12 +17,18 @@ const emit = defineEmits<{ close: [confirmed: boolean] }>()
const inUse = computed(() => props.item.used_in > 0)
const title = computed(() =>
inUse.value ? "Delete an image that's in use?" : 'Delete this image?',
inUse.value ? t("Delete an image that's in use?") : t('Delete this image?'),
)
const description = computed(() =>
inUse.value
? `"${props.item.filename}" is used in ${pluralArticles(props.item.used_in)}. If you delete it, they will show a broken image. This can't be undone.`
: `"${props.item.filename}" will be deleted from your library. No article uses it. This can't be undone.`,
? t(
"\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.",
[props.item.filename, pluralArticles(props.item.used_in)],
)
: t(
"\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.",
[props.item.filename],
),
)
</script>
@@ -28,9 +36,9 @@ const description = computed(() =>
<ConfirmDialog
:title="title"
:description="description"
confirm-label="Delete image"
cancel-label="Cancel"
pending-label="Deleting…"
:confirm-label="t('Delete image')"
:cancel-label="t('Cancel')"
:pending-label="t('Deleting…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
+13 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { DuplicateGroup } from '@/lib/mediaDuplicates'
@@ -18,8 +20,8 @@ const emit = defineEmits<{
}>()
function uploaded(copy: MediaItem) {
const by = copy.uploaded_by ? ` by ${copy.uploaded_by}` : ''
return `Uploaded ${formatMediaDate(copy.created_at)}${by}`
const by = copy.uploaded_by ? t(' by {0}', [copy.uploaded_by]) : ''
return t('Uploaded {0}{1}', [formatMediaDate(copy.created_at), by])
}
</script>
@@ -32,12 +34,14 @@ function uploaded(copy: MediaItem) {
class="border border-default bg-default"
>
<header class="flex flex-col gap-0.5 border-b border-default px-3.5 py-3">
<h3 class="font-semibold text-highlighted">Group {{ index + 1 }}</h3>
<h3 class="font-semibold text-highlighted">{{ t('Group') }} {{ index + 1 }}</h3>
<span class="text-muted">{{ group.header }}</span>
</header>
<div
class="grid md:grid-cols-(--columns) md:gap-4 md:p-4"
:style="{ '--columns': `repeat(${group.copies.length}, minmax(0, 1fr))` }"
:style="{
'--columns': `repeat(${group.copies.length}, minmax(0, 1fr))`,
}"
>
<div
v-for="copy in group.copies"
@@ -70,7 +74,7 @@ function uploaded(copy: MediaItem) {
<span
v-if="copy.id === group.suggested.id"
class="inline-flex h-5 items-center bg-primary px-1.5 text-xs font-medium text-inverted"
>Suggested</span
>{{ t('Suggested') }}</span
>
</div>
<span
@@ -88,7 +92,8 @@ function uploaded(copy: MediaItem) {
class="self-start font-medium underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="emit('open', copy.id, $event.currentTarget as HTMLElement)"
>
Used in {{ pluralArticles(copy.used_in) }}
{{ t('Used in') }}
{{ pluralArticles(copy.used_in) }}
</button>
</div>
</div>
@@ -105,12 +110,12 @@ function uploaded(copy: MediaItem) {
"
@click="emit('merge', group, copy)"
>
Merge into this one
{{ t('Merge into this one') }}
</UButton>
</div>
</div>
<footer class="border-t border-default px-3.5 py-2.5 text-xs/5 text-muted">
{{ FOOTER }}
{{ t(FOOTER) }}
</footer>
</section>
</div>
+30 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
@@ -18,30 +20,46 @@ const truncatedQuery = computed(() => {
const variants = computed(() => ({
'no-articles': {
icon: 'i-lucide-file-text',
title: 'No articles yet',
description: 'Write your first one. It stays a draft until you publish it.',
action: 'New article',
get title() {
return t('No articles yet')
},
get description() {
return t('Write your first one. It stays a draft until you publish it.')
},
action: t('New article'),
primary: true,
},
'no-results': {
icon: 'i-lucide-search',
title: `No articles match "${truncatedQuery.value}"`,
description: 'Check the spelling, or search for a shorter part of the title.',
action: 'Clear search',
get title() {
return t('No articles match \u0022{0}\u0022', [truncatedQuery.value])
},
get description() {
return t('Check the spelling, or search for a shorter part of the title.')
},
action: t('Clear search'),
primary: false,
},
'no-drafts': {
icon: 'i-lucide-pencil-line',
title: 'No drafts',
description: "Everything you've written is online.",
action: 'New article',
get title() {
return t('No drafts')
},
get description() {
return t("Everything you've written is online.")
},
action: t('New article'),
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
title: 'Nothing is online yet',
description: 'Publish a draft and it will appear on your website.',
action: 'Show drafts',
get title() {
return t('Nothing is online yet')
},
get description() {
return t('Publish a draft and it will appear on your website.')
},
action: t('Show drafts'),
primary: false,
},
}))
+8 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
@@ -14,7 +16,12 @@ const activeTab = ref('fetch')
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
{ label: 'Response', value: 'response' },
{
get label() {
return t('Response')
},
value: 'response',
},
]
</script>
+30 -14
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -8,7 +10,7 @@ const props = withDefaults(
breadcrumb?: string
}>(),
{
breadcrumb: 'Home',
breadcrumb: undefined,
},
)
@@ -21,25 +23,39 @@ const router = useRouter()
const copy = computed(() => {
if (props.kind === 'not-found') {
return {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
get title() {
return t('Page not found')
},
get text() {
return t('The link may be mistyped, or the page may have moved.')
},
primary: t('Go to Home'),
secondary: t('All articles'),
}
}
if (props.kind === 'article-not-found') {
return {
title: "This article doesn't exist.",
text: 'It may have been deleted, or the link is wrong.',
primary: 'All articles',
get title() {
return t("This article doesn't exist.")
},
get text() {
return t('It may have been deleted, or the link is wrong.')
},
primary: t('All articles'),
secondary: '',
}
}
return {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
get title() {
return t('Something went wrong')
},
get text() {
return t(
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
)
},
primary: t('Try again'),
secondary: t('Go to Home'),
}
})
@@ -66,8 +82,8 @@ function onSecondary() {
<template>
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb">
<span>{{ breadcrumb }}</span>
<nav v-if="kind !== 'article-not-found'" :aria-label="t('Breadcrumb')">
<span>{{ breadcrumb ?? t('Home') }}</span>
</nav>
<span
v-if="kind === 'article-not-found'"
+88 -39
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import MediaPicker from '@/components/MediaPicker.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -72,8 +74,20 @@ const DROP_ZONE_UI = {
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const TAB_ITEMS = [
{ label: 'Upload', value: 'upload', icon: 'i-lucide-upload' },
{ label: 'Library', value: 'library', icon: 'i-lucide-images' },
{
get label() {
return t('Upload')
},
value: 'upload',
icon: 'i-lucide-upload',
},
{
get label() {
return t('Library')
},
value: 'library',
icon: 'i-lucide-images',
},
]
const open = defineModel<boolean>('open', { default: false })
@@ -147,7 +161,7 @@ const duplicateText = computed(() =>
: '',
)
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const cancelLabel = computed(() => (phase.value === 'uploading' ? t('Cancel upload') : t('Cancel')))
const showCancel = computed(
() =>
@@ -163,7 +177,9 @@ const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'c
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image'))
const modalTitle = computed(() =>
phase.value === 'crop' ? t('Crop and resize') : t('Add an image'),
)
const modalDescription = computed(() => {
if (phase.value !== 'crop' || !chosenFile.value) {
@@ -203,9 +219,12 @@ const outputSizeText = computed(() =>
formatEstimatedSize(estimateOutputBytes(outputSize.value.width, outputSize.value.height)),
)
const monoLine = computed(
() =>
`Uploads as ${outputSize.value.width} × ${outputSize.value.height} px · about ${outputSizeText.value}`,
const monoLine = computed(() =>
t('Uploads as {0} × {1} px · about {2}', [
outputSize.value.width,
outputSize.value.height,
outputSizeText.value,
]),
)
const targetWidthPixels = computed(() => widthPresetPixels(widthLabel.value))
@@ -214,9 +233,11 @@ const showNarrowNote = computed(
() => targetWidthPixels.value !== null && targetWidthPixels.value > cropWidth.value,
)
const narrowNoteText = computed(
() =>
`The crop is ${cropWidth.value} px wide, so it's uploaded at that size. Enlarging it would make it blurry.`,
const narrowNoteText = computed(() =>
t(
"The crop is {0} px wide, so it's uploaded at that size. Enlarging it would make it blurry.",
[cropWidth.value],
),
)
const dimsLabel = computed(() => `${cropWidth.value} × ${cropHeight.value}`)
@@ -505,12 +526,18 @@ function stagePixelScale(): number {
return boundsWidth.value / box.width
}
function pointFromEvent(event: MouseEvent | TouchEvent): { x: number; y: number } {
function pointFromEvent(event: MouseEvent | TouchEvent): {
x: number
y: number
} {
if ('touches' in event && event.touches.length > 0) {
return { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
}
if ('changedTouches' in event && event.changedTouches.length > 0) {
return { x: event.changedTouches[0]!.clientX, y: event.changedTouches[0]!.clientY }
return {
x: event.changedTouches[0]!.clientX,
y: event.changedTouches[0]!.clientY,
}
}
const mouseEvent = event as MouseEvent
return { x: mouseEvent.clientX, y: mouseEvent.clientY }
@@ -548,7 +575,11 @@ function onFrameDragMove(event: MouseEvent | TouchEvent) {
const dx = (point.x - moveOrigin.x) * scale
const dy = (point.y - moveOrigin.y) * scale
rect.value = clampRect(
{ ...moveOrigin.rect, x: moveOrigin.rect.x + dx, y: moveOrigin.rect.y + dy },
{
...moveOrigin.rect,
x: moveOrigin.rect.x + dx,
y: moveOrigin.rect.y + dy,
},
boundsWidth.value,
boundsHeight.value,
)
@@ -775,11 +806,13 @@ onUnmounted(() => {
<template #label>
{{
phase === 'dragging-over'
? 'Drop to upload'
: 'Drop an image here, or choose a file'
? t('Drop to upload')
: t('Drop an image here, or choose a file')
}}
</template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
<template #description>{{
t('PNG, JPEG, WEBP, SVG · max 10 MB')
}}</template>
</UFileUpload>
</div>
@@ -795,13 +828,17 @@ onUnmounted(() => {
class="h-13.5 w-18 border border-default"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<p class="font-semibold text-highlighted">Already in your library</p>
<p class="text-xs text-toned">{{ duplicateText }}</p>
<p class="font-semibold text-highlighted">
{{ t('Already in your library') }}
</p>
<p class="text-xs text-toned">
{{ duplicateText }}
</p>
</div>
</div>
<div class="flex flex-col gap-2 md:flex-row">
<UButton class="h-11 justify-center md:h-8" @click="useExisting">
Use existing
{{ t('Use existing') }}
</UButton>
<UButton
color="neutral"
@@ -809,7 +846,7 @@ onUnmounted(() => {
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 md:h-8"
@click="uploadAnyway"
>
Upload anyway
{{ t('Upload anyway') }}
</UButton>
</div>
</div>
@@ -822,7 +859,9 @@ onUnmounted(() => {
<div
ref="imageBoxRef"
class="relative h-full max-w-full"
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
:style="{
aspectRatio: `${boundsWidth} / ${boundsHeight}`,
}"
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
role="img"
>
@@ -846,7 +885,11 @@ onUnmounted(() => {
data-testid="crop-frame"
tabindex="0"
role="group"
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
:aria-label="
t(
'Crop area. Arrow keys move it, Shift and arrow keys resize it.',
)
"
class="absolute cursor-move touch-none outline outline-white"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
@@ -900,7 +943,9 @@ onUnmounted(() => {
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1">
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
<span class="flex-1 font-medium text-highlighted">{{
t('Aspect ratio')
}}</span>
<UButton
color="neutral"
variant="ghost"
@@ -908,7 +953,7 @@ onUnmounted(() => {
icon="i-lucide-rotate-ccw-square"
@click="rotateImage"
>
Rotate
{{ t('Rotate') }}
</UButton>
<UButton
color="neutral"
@@ -917,12 +962,12 @@ onUnmounted(() => {
icon="i-lucide-undo-2"
@click="resetCrop"
>
Reset
{{ t('Reset') }}
</UButton>
</div>
<div
role="radiogroup"
aria-label="Aspect ratio"
:aria-label="t('Aspect ratio')"
class="flex w-full border border-accented sm:w-max"
>
<button
@@ -940,16 +985,16 @@ onUnmounted(() => {
"
@click="selectRatio(ratio)"
>
{{ ratio }}
{{ ratio === 'Original' || ratio === 'Free' ? t(ratio) : ratio }}
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Width</span>
<span class="font-medium text-highlighted">{{ t('Width') }}</span>
<div
role="radiogroup"
aria-label="Width"
:aria-label="t('Width')"
class="flex w-full border border-accented sm:w-max"
>
<button
@@ -967,7 +1012,7 @@ onUnmounted(() => {
"
@click="selectWidth(widthOption)"
>
{{ widthOption }}
{{ widthOption === 'Original' ? t(widthOption) : widthOption }}
</button>
</div>
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
@@ -992,7 +1037,7 @@ onUnmounted(() => {
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
/>
<p class="text-sm text-highlighted">
<template v-if="succeeded">Image added</template>
<template v-if="succeeded">{{ t('Image added') }}</template>
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
</p>
<UProgress
@@ -1014,15 +1059,19 @@ onUnmounted(() => {
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
>
<UIcon name="i-lucide-file-x" class="size-6 text-error" />
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p>
<p class="text-sm font-semibold text-highlighted">
{{ rejectedTitle }}
</p>
<p class="text-sm text-toned">{{ rejectedText }}</p>
</div>
</div>
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
<p class="text-sm font-semibold text-highlighted">Upload failed</p>
<p class="text-sm font-semibold text-highlighted">
{{ t('Upload failed') }}
</p>
<p class="text-sm text-toned">
The server didn't accept the file. Your article hasn't changed.
{{ t("The server didn't accept the file. Your article hasn't changed.") }}
</p>
</div>
</div>
@@ -1047,7 +1096,7 @@ onUnmounted(() => {
:disabled="!selected"
@click="insertSelected"
>
Insert image
{{ t('Insert image') }}
</UButton>
<UButton
v-if="showRejectedActions"
@@ -1055,7 +1104,7 @@ onUnmounted(() => {
:class="FOOTER_BUTTON_CLASS"
@click="chooseAnotherFile"
>
Choose another file
{{ t('Choose another file') }}
</UButton>
<UButton
v-if="showFailedActions"
@@ -1063,7 +1112,7 @@ onUnmounted(() => {
:class="FOOTER_BUTTON_CLASS"
@click="tryAgain"
>
Try again
{{ t('Try again') }}
</UButton>
<template v-if="showCropActions">
<UButton
@@ -1074,10 +1123,10 @@ onUnmounted(() => {
class="text-highlighted"
@click="chooseAnotherFile"
>
Choose another file
{{ t('Choose another file') }}
</UButton>
<UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload">
Upload image
{{ t('Upload image') }}
</UButton>
</template>
</div>
+11 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = withDefaults(
@@ -13,19 +15,25 @@ const props = withDefaults(
const badges = {
accepted: {
label: 'Accepted',
get label() {
return t('Accepted')
},
icon: 'i-lucide-circle-check',
color: 'success',
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
},
pending: {
label: 'Pending',
get label() {
return t('Pending')
},
icon: 'i-lucide-mail',
color: 'warning',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
expired: {
label: 'Expired',
get label() {
return t('Expired')
},
icon: 'i-lucide-clock-alert',
color: 'neutral',
class: 'border border-accented bg-transparent text-muted',
+20 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
@@ -24,18 +26,18 @@ const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const emailError = computed(
() => serverError.value || (touched.value && !emailValid.value ? INVALID_EMAIL_MESSAGE : ''),
() => serverError.value || (touched.value && !emailValid.value ? t(INVALID_EMAIL_MESSAGE) : ''),
)
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
return t("Something in the form isn't valid. Check the highlighted fields.")
}
if (formAlert.value === 'rateLimited') {
return 'Too many invites. Wait a moment, then try again.'
return t('Too many invites. Wait a moment, then try again.')
}
if (formAlert.value === 'failed') {
return "Couldn't send the invite. Try again in a moment."
return t("Couldn't send the invite. Try again in a moment.")
}
return ''
})
@@ -82,7 +84,9 @@ function onBlur() {
}
function messageForConflict(error: ApiError) {
return error.body === PENDING_INVITE_MESSAGE ? PENDING_INVITE_MESSAGE : HAS_ACCOUNT_MESSAGE
return error.body === PENDING_INVITE_MESSAGE
? t(PENDING_INVITE_MESSAGE)
: t(HAS_ACCOUNT_MESSAGE)
}
function showFailure(error: unknown) {
@@ -143,8 +147,12 @@ function onCancel() {
<template>
<UModal
:open="open"
title="Invite a user"
description="We'll email them a link to choose a username and password. The link expires after 3 days."
:title="t('Invite a user')"
:description="
t(
'We\'ll email them a link to choose a username and password. The link expires after 3 days.',
)
"
:dismissible="!submitting"
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
@update:open="onOpenChange"
@@ -173,9 +181,9 @@ function onCancel() {
/>
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Email"
:label="t('Email')"
name="email"
:error="emailError || undefined"
:error="translate(emailError) || undefined"
:ui="{ error: 'mt-1.5 text-xs/5' }"
>
<template v-if="emailError" #error="{ error }">
@@ -184,7 +192,7 @@ function onCancel() {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<UInput
@@ -215,7 +223,7 @@ function onCancel() {
:disabled="submitting"
@click="onCancel"
>
Cancel
{{ t('Cancel') }}
</UButton>
<UButton
block
@@ -225,7 +233,7 @@ function onCancel() {
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Sending…' : 'Send invite' }}
{{ submitting ? t('Sending…') : t('Send invite') }}
</UButton>
</div>
</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>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
@@ -10,7 +12,7 @@ defineProps<{
<template>
<MediaBadge
:kind="published ? 'online' : 'draft'"
:label="published ? 'Online' : 'Draft'"
:label="published ? t('Online') : t('Draft')"
:size="size"
/>
</template>
+6 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { useElementBounding, useMediaQuery } from '@vueuse/core'
@@ -24,14 +26,14 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
<template>
<div
role="region"
aria-label="Selection"
:aria-label="t('Selection')"
data-testid="media-bulk-bar"
class="fixed bottom-4 z-(--z-banner) flex items-center gap-2 bg-inverted py-2 pr-2 pl-4 text-inverted shadow-(--shadow-popover) max-md:inset-x-4 md:bottom-6 md:left-(--bulk-centre) md:w-100 md:-translate-x-1/2"
:class="{ invisible: obscured }"
:inert="obscured || undefined"
:style="isDesktop ? { '--bulk-centre': centre } : undefined"
>
<span class="flex-1 font-medium">{{ count }} selected</span>
<span class="flex-1 font-medium">{{ formatNumber(count) }} {{ t('selected') }}</span>
<UButton
color="error"
icon="i-lucide-trash-2"
@@ -40,7 +42,7 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
class="h-11 px-3 md:h-8"
@click="emit('delete')"
>
Delete
{{ t('Delete') }}
</UButton>
<UButton
color="neutral"
@@ -49,7 +51,7 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
class="h-11 border border-(--ui-text-inverted) bg-transparent px-3 text-inverted ring-0 hover:bg-transparent md:h-8"
@click="emit('clear')"
>
Clear
{{ t('Clear') }}
</UButton>
</div>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
@@ -7,7 +9,7 @@ defineProps<{
</script>
<template>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="`Used in ${item.used_in}`" />
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="t('Used in {0}', [item.used_in])" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -65,7 +67,7 @@ const emit = defineEmits<{
>
<MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:tabbable="false"
@toggle="emit('toggle', item.id)"
/>
+57 -16
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
@@ -47,11 +49,36 @@ const metadata = computed(() => {
return []
}
return [
{ label: 'Type', value: formatType(item.content_type) },
{ label: 'Size', value: formatSize(item.size) },
{ label: 'Dimensions', value: formatDimensions(item.width, item.height) },
{ label: 'Uploaded', value: formatMediaDate(item.created_at) },
{ label: 'Uploaded by', value: item.uploaded_by },
{
get label() {
return t('Type')
},
value: formatType(item.content_type),
},
{
get label() {
return t('Size')
},
value: formatSize(item.size),
},
{
get label() {
return t('Dimensions')
},
value: formatDimensions(item.width, item.height),
},
{
get label() {
return t('Uploaded')
},
value: formatMediaDate(item.created_at),
},
{
get label() {
return t('Uploaded by')
},
value: item.uploaded_by,
},
]
})
@@ -63,8 +90,18 @@ const duplicateAlert = computed(() => {
}
const names = props.copies.map((copy) => copy.filename)
return {
title: `Identical to ${props.copies.length} other ${props.copies.length === 1 ? 'image' : 'images'}`,
description: `${joinNames(names)} ${names.length === 1 ? 'has' : 'have'} the same content.`,
get title() {
return t('Identical to {0} other {1}', [
props.copies.length,
props.copies.length === 1 ? 'image' : 'images',
])
},
get description() {
return t('{0} {1} the same content.', [
joinNames(names),
names.length === 1 ? 'has' : 'have',
])
},
}
})
</script>
@@ -74,7 +111,7 @@ const duplicateAlert = computed(() => {
v-model:open="open"
side="right"
:title="item?.filename ?? ''"
description="Image details"
:description="t('Image details')"
:close="closeButton"
:ui="{
content: 'w-full max-w-none md:max-w-115',
@@ -102,7 +139,7 @@ const duplicateAlert = computed(() => {
:class="ACTION_BUTTON"
@click="emit('copy-url')"
>
Copy URL
{{ t('Copy URL') }}
</UButton>
<UButton
color="neutral"
@@ -112,7 +149,7 @@ const duplicateAlert = computed(() => {
:class="ACTION_BUTTON"
@click="emit('rename')"
>
Rename
{{ t('Rename') }}
</UButton>
<UButton
color="neutral"
@@ -123,7 +160,7 @@ const duplicateAlert = computed(() => {
class="text-error"
@click="emit('delete')"
>
Delete
{{ t('Delete') }}
</UButton>
</div>
@@ -149,7 +186,7 @@ const duplicateAlert = computed(() => {
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('review-duplicates')"
>
Review duplicates
{{ t('Review duplicates') }}
</UButton>
</template>
</UAlert>
@@ -169,8 +206,8 @@ const duplicateAlert = computed(() => {
<h3 class="text-base font-semibold text-highlighted">
{{
item.used_in > 0
? `Used in ${pluralArticles(item.used_in)}`
: 'Not used in any article'
? t('Used in {0}', [pluralArticles(item.used_in)])
: t('Not used in any article')
}}
</h3>
<template v-if="item.used_in > 0">
@@ -196,11 +233,15 @@ const duplicateAlert = computed(() => {
</li>
</ul>
<p class="text-xs text-muted">
Counted from the text of your articles each time you open this page.
{{
t(
'Counted from the text of your articles each time you open this page.',
)
}}
</p>
</template>
<p v-else class="text-muted">
This image is safe to delete. No article shows it.
{{ t('This image is safe to delete. No article shows it.') }}
</p>
</section>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
</script>
@@ -9,7 +10,7 @@ import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
class="pointer-events-none fixed inset-2 top-16 z-(--z-popover) flex flex-col items-center justify-center gap-2 border-2 border-dashed border-primary bg-secondary-200 text-center text-primary-900 md:inset-3 md:top-15 dark:bg-secondary-300"
>
<UIcon name="i-lucide-upload" class="size-7" />
<span class="text-lg font-semibold">{{ DROP_TITLE }}</span>
<span class="text-primary-600">{{ DROP_TEXT }}</span>
<span class="text-lg font-semibold">{{ t(DROP_TITLE) }}</span>
<span class="text-primary-600">{{ t(DROP_TEXT) }}</span>
</div>
</template>
+27 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
@@ -19,26 +21,40 @@ const current = computed(() => {
if (props.variant === 'first-run') {
return {
icon: 'i-lucide-images',
title: 'No images yet',
description: 'Upload your first image. You can use it in any article.',
action: 'Upload an image',
get title() {
return t('No images yet')
},
get description() {
return t('Upload your first image. You can use it in any article.')
},
action: t('Upload an image'),
}
}
if (props.variant === 'no-duplicates') {
return {
icon: 'i-lucide-copy',
title: 'No duplicates',
description: 'Every image in your library is different.',
action: 'Back to all images',
get title() {
return t('No duplicates')
},
get description() {
return t('Every image in your library is different.')
},
action: t('Back to all images'),
}
}
return {
icon: 'i-lucide-search',
title: truncatedQuery.value
? `No images match "${truncatedQuery.value}"`
: 'No images match this filter',
description: `Nothing matches in ${props.filterLabel ?? 'All'}. Check the spelling, or clear the filters.`,
action: 'Clear filters',
get title() {
return truncatedQuery.value
? t('No images match \u0022{0}\u0022', [truncatedQuery.value])
: t('No images match this filter')
},
get description() {
return t('Nothing matches in {0}. Check the spelling, or clear the filters.', [
props.filterLabel ?? t('All'),
])
},
action: t('Clear filters'),
}
})
</script>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
@@ -127,7 +129,7 @@ function onKeyup(event: KeyboardEvent) {
>
<MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:tabbable="false"
@toggle="emit('toggle', item.id)"
/>
+26 -19
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -55,7 +57,7 @@ const firstLoadSkeletons = computed(() =>
)
const searching = computed(() => props.query !== '')
const summary = computed(() =>
searching.value ? 'Newest first' : `Newest first · ${props.total} images`,
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
)
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
const showEmptyLibrary = computed(
@@ -66,7 +68,7 @@ const showNoResults = computed(
)
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
const noResultsTitle = computed(() => `No images match "${props.query}"`)
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
function clearDebounce() {
if (debounceTimer !== null) {
@@ -133,10 +135,13 @@ onBeforeUnmount(clearDebounce)
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
:placeholder="t('Search by filename')"
:aria-label="t('Search by filename')"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: SEARCH_ICON_SIZE }"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: SEARCH_ICON_SIZE,
}"
>
<template v-if="searchText" #trailing>
<UButton
@@ -144,7 +149,7 @@ onBeforeUnmount(clearDebounce)
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
@@ -159,8 +164,8 @@ onBeforeUnmount(clearDebounce)
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your library"
description="Check your connection and try again."
:title="t('Couldn\'t load your library')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
@@ -172,7 +177,7 @@ onBeforeUnmount(clearDebounce)
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
Try again
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@@ -181,13 +186,13 @@ onBeforeUnmount(clearDebounce)
v-else-if="showEmptyLibrary"
variant="naked"
icon="i-lucide-images"
title="Your library is empty"
description="Images you upload appear here, ready to use in any article."
:title="t('Your library is empty')"
:description="t('Images you upload appear here, ready to use in any article.')"
:ui="EMPTY_STATE_UI"
>
<template #actions>
<UButton icon="i-lucide-upload" class="h-11 px-4 md:h-8" @click="emit('upload')">
Upload an image
{{ t('Upload an image') }}
</UButton>
</template>
</UEmpty>
@@ -197,7 +202,7 @@ onBeforeUnmount(clearDebounce)
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
description="Check the spelling, or search for a shorter part of the filename."
:description="t('Check the spelling, or search for a shorter part of the filename.')"
:ui="EMPTY_STATE_UI"
>
<template #actions>
@@ -207,7 +212,7 @@ onBeforeUnmount(clearDebounce)
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
Clear search
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
@@ -259,7 +264,7 @@ onBeforeUnmount(clearDebounce)
<span
v-if="isInArticle(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
>In this article</span
>{{ t('In this article') }}</span
>
<span
v-if="isSelected(item)"
@@ -291,10 +296,12 @@ onBeforeUnmount(clearDebounce)
:icon="loadingMore ? 'i-lucide-loader-circle' : undefined"
:aria-disabled="loadingMore || undefined"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 aria-disabled:opacity-40 md:h-8"
:ui="{ leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4' }"
:ui="{
leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4',
}"
@click="emit('load-more')"
>
{{ loadingMore ? 'Loading…' : 'Load more' }}
{{ loadingMore ? t('Loading…') : t('Load more') }}
</UButton>
</div>
</template>
@@ -317,7 +324,7 @@ onBeforeUnmount(clearDebounce)
</div>
</div>
<UFormField
label="Alt text (optional)"
:label="t('Alt text (optional)')"
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
:ui="{
label: 'font-medium text-highlighted',
@@ -327,7 +334,7 @@ onBeforeUnmount(clearDebounce)
<UInput
v-model="alt"
type="text"
placeholder="e.g. North wall of the kitchen before plastering"
:placeholder="t('e.g. North wall of the kitchen before plastering')"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
/>
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { SelectAllState } from '@/composables/useMediaSelection'
@@ -15,14 +17,14 @@ const modelValue = computed(() => {
}
return props.state === 'all'
})
const imageCount = computed(() => `${props.count} ${props.count === 1 ? 'image' : 'images'}`)
const imageCount = computed(() => t(props.count === 1 ? '{0} image' : '{0} images', [props.count]))
</script>
<template>
<div class="flex items-center gap-2.5 text-muted">
<UCheckbox
:model-value="modelValue"
label="Select all"
:label="t('Select all')"
color="primary"
class="-ml-2.75 md:ml-0"
:ui="{
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{
count: number
}>()
@@ -10,7 +12,7 @@ defineProps<{
data-testid="media-skeleton"
class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4"
>
<span role="status" class="sr-only">Loading images</span>
<span role="status" class="sr-only">{{ t('Loading images') }}</span>
<div
v-for="n in count"
:key="n"
+13 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -44,19 +46,19 @@ function open(event: Event, id: number) {
<span class="flex justify-center">
<MediaCheckbox
size="sm"
label="Select all"
:label="t('Select all')"
:state="ALL_STATES[allState]"
@toggle="emit('toggle-all')"
/>
</span>
<span>Filename</span>
<span>Dimensions</span>
<span>Size</span>
<span>Type</span>
<span>Used in</span>
<span>Uploaded by</span>
<span>{{ t('Filename') }}</span>
<span>{{ t('Dimensions') }}</span>
<span>{{ t('Size') }}</span>
<span>{{ t('Type') }}</span>
<span>{{ t('Used in') }}</span>
<span>{{ t('Uploaded by') }}</span>
<span class="flex items-center gap-1 text-highlighted">
Uploaded
{{ t('Uploaded') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
</div>
@@ -77,7 +79,7 @@ function open(event: Event, id: number) {
<span class="relative z-10 flex justify-center">
<MediaCheckbox
size="sm"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
@toggle="emit('toggle', item.id)"
/>
@@ -94,7 +96,7 @@ function open(event: Event, id: number) {
>
{{ item.filename }}
</button>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
<MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
</span>
<span>{{ formatDimensions(item.width, item.height) }}</span>
<span>{{ formatSize(item.size) }}</span>
@@ -109,7 +111,7 @@ function open(event: Event, id: number) {
>
{{ pluralArticles(item.used_in) }}
</button>
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</span>
<span class="truncate pr-2">{{ item.uploaded_by }}</span>
<span>{{ formatMediaDate(item.created_at) }}</span>
+60 -17
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
@@ -7,15 +9,35 @@ import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const SEARCH_DEBOUNCE_MS = 120
const SORT_LABELS: Record<MediaSort, string> = {
newest: 'Newest',
oldest: 'Oldest',
name: 'Name',
largest: 'Largest',
get newest() {
return t('Newest')
},
get oldest() {
return t('Oldest')
},
get name() {
return t('Name')
},
get largest() {
return t('Largest')
},
}
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
{ value: 'grid', icon: 'i-lucide-layout-grid', label: 'Grid view' },
{ value: 'list', icon: 'i-lucide-list', label: 'List view' },
{
value: 'grid',
icon: 'i-lucide-layout-grid',
get label() {
return t('Grid view')
},
},
{
value: 'list',
icon: 'i-lucide-list',
get label() {
return t('List view')
},
},
]
const props = defineProps<{
@@ -68,9 +90,27 @@ function clearSearch() {
onBeforeUnmount(clearDebounce)
const tabItems = computed(() => [
{ label: 'All', value: 'all', count: props.counts?.all },
{ label: 'Unused', value: 'unused', count: props.counts?.unused },
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates },
{
get label() {
return t('All')
},
value: 'all',
count: props.counts?.all,
},
{
get label() {
return t('Unused')
},
value: 'unused',
count: props.counts?.unused,
},
{
get label() {
return t('Duplicates')
},
value: 'duplicates',
count: props.counts?.duplicates,
},
])
const tab = computed({
@@ -115,12 +155,12 @@ const sortItems = computed(() =>
v-if="item.value === 'duplicates' && item.count"
data-testid="duplicates-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-(--rc-info-soft) px-1.25 font-mono text-[0.6875rem] font-medium text-info"
>{{ item.count }}</span
>{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
>
<span
v-else-if="item.value !== 'duplicates' && item.count !== undefined"
class="font-mono text-[0.6875rem] font-medium text-muted"
>{{ item.count }}</span
>{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
>
</span>
</template>
@@ -130,10 +170,13 @@ const sortItems = computed(() =>
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
:placeholder="t('Search by filename')"
:aria-label="t('Search by filename')"
class="order-1 w-full md:order-none md:mb-1.5 md:w-60"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: 'size-3.5' }"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: 'size-3.5',
}"
>
<template v-if="searchText" #trailing>
<UButton
@@ -141,7 +184,7 @@ const sortItems = computed(() =>
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
@@ -154,7 +197,7 @@ const sortItems = computed(() =>
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
aria-label="Sort"
:aria-label="t('Sort')"
class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
>
@@ -162,7 +205,7 @@ const sortItems = computed(() =>
</UButton>
</UDropdownMenu>
<div role="group" aria-label="View" class="flex shrink-0 border border-accented">
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
<UButton
v-for="option in VIEW_OPTIONS"
:key="option.value"
+17 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue'
import type { UploadRow } from '@/composables/useMediaUpload'
import { formatUploadProgress } from '@/lib/imageUpload'
@@ -48,7 +50,7 @@ function percent(row: UploadRow) {
<template>
<section
data-testid="media-upload-panel"
aria-label="Uploads"
:aria-label="t('Uploads')"
class="flex flex-col border border-default bg-default"
>
<div class="flex items-center gap-3 px-3.5 py-3">
@@ -64,7 +66,7 @@ function percent(row: UploadRow) {
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
@click="header.phase === 'working' ? emit('cancel-all') : emit('dismiss')"
>
{{ header.phase === 'working' ? 'Cancel all' : 'Dismiss' }}
{{ header.phase === 'working' ? t('Cancel all') : t('Dismiss') }}
</UButton>
</div>
@@ -83,7 +85,7 @@ function percent(row: UploadRow) {
</div>
<template v-if="row.status === 'waiting'">
<span class="text-muted">Waiting</span>
<span class="text-muted">{{ t('Waiting') }}</span>
</template>
<template v-else-if="row.status === 'uploading'">
@@ -91,19 +93,22 @@ function percent(row: UploadRow) {
<UProgress
:model-value="percent(row)"
size="sm"
:ui="{ base: 'rounded-none', indicator: 'rounded-none' }"
:ui="{
base: 'rounded-none',
indicator: 'rounded-none',
}"
/>
</template>
<template v-else-if="row.status === 'added'">
<span class="flex items-center gap-1.5 text-success">
<UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0" />
{{ ADDED_TEXT }}
{{ t(ADDED_TEXT) }}
</span>
</template>
<template v-else-if="row.status === 'duplicate' && row.existing">
<span class="font-semibold text-highlighted">{{ DUPLICATE_TITLE }}</span>
<span class="font-semibold text-highlighted">{{ t(DUPLICATE_TITLE) }}</span>
<span class="text-xs/5 text-toned">{{
duplicateText(row.existing, formatMediaDate)
}}</span>
@@ -114,7 +119,7 @@ function percent(row: UploadRow) {
:class="BUTTON"
@click="emit('use-existing', row.key)"
>
Use existing
{{ t('Use existing') }}
</UButton>
<UButton
color="neutral"
@@ -122,7 +127,7 @@ function percent(row: UploadRow) {
:class="BUTTON"
@click="emit('upload-anyway', row.key)"
>
Upload anyway
{{ t('Upload anyway') }}
</UButton>
</div>
</template>
@@ -138,9 +143,9 @@ function percent(row: UploadRow) {
<template v-else-if="row.status === 'failed'">
<span class="flex items-center gap-1.5 font-semibold text-error">
<UIcon name="i-lucide-circle-x" class="size-3.5 shrink-0" />
{{ FAILED_TITLE }}
{{ t(FAILED_TITLE) }}
</span>
<span class="text-xs/5 text-toned">{{ FAILED_TEXT }}</span>
<span class="text-xs/5 text-toned">{{ t(FAILED_TEXT) }}</span>
<div class="flex">
<UButton
color="neutral"
@@ -150,7 +155,7 @@ function percent(row: UploadRow) {
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry', row.key)"
>
Try again
{{ t('Try again') }}
</UButton>
</div>
</template>
@@ -160,7 +165,7 @@ function percent(row: UploadRow) {
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="`Cancel upload of ${row.name}`"
:aria-label="t('Cancel upload of {0}', [row.name])"
class="size-6 justify-center text-muted"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('cancel-row', row.key)"
+18 -9
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -15,21 +17,27 @@ const props = defineProps<{
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const otherCount = computed(() => props.copies.length - 1)
const others = computed(() => (otherCount.value === 1 ? 'copy' : `${otherCount.value} copies`))
const others = computed(() =>
otherCount.value === 1 ? t('copy') : t('{0} copies', [otherCount.value]),
)
const articles = computed(() => mergeArticleCount(props.copies, props.keep.id))
const description = computed(
() =>
`Articles that use the other ${others.value} will be switched to ${props.keep.filename}, and the other ${others.value} will be deleted. ${pluralArticles(articles.value)} will be updated. This can't be undone.`,
const description = computed(() =>
t(
"Articles that use the other {0} will be switched to {1}, and the other {2} will be deleted. {3} will be updated. This can't be undone.",
[others.value, props.keep.filename, others.value, pluralArticles(articles.value)],
),
)
</script>
<template>
<ConfirmDialog
:title="`Merge into ${keep.filename}?`"
:title="t('Merge into {0}?', [keep.filename])"
:description="description"
:confirm-label="`Merge ${otherCount === 1 ? '1 copy' : `${otherCount} copies`}`"
cancel-label="Cancel"
pending-label="Merging…"
:confirm-label="
t('Merge {0}', [otherCount === 1 ? t('1 copy') : t('{0} copies', [otherCount])])
"
:cancel-label="t('Cancel')"
:pending-label="t('Merging…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
@@ -40,7 +48,8 @@ const description = computed(
<div class="flex min-w-0 flex-col">
<span class="truncate font-medium text-highlighted">{{ keep.filename }}</span>
<span class="text-xs/5 text-muted"
>Kept · used in {{ pluralArticles(keep.used_in) }} now</span
>{{ t('Kept · used in') }} {{ pluralArticles(keep.used_in) }}
{{ t('now') }}</span
>
</div>
</div>
+33 -19
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, useApi } from '@/composables/useApi'
@@ -34,10 +36,10 @@ const titleLengthError = computed(() => {
return ''
}
if (titleLength.value < TITLE_MIN_LENGTH) {
return 'Use at least 3 characters.'
return t('Use at least 3 characters.')
}
if (titleLength.value > TITLE_MAX_LENGTH) {
return 'Use 3 to 200 characters.'
return t('Use 3 to 200 characters.')
}
return ''
})
@@ -46,7 +48,7 @@ const titleError = computed(() => titleServerError.value || titleLengthError.val
const bodyError = computed(() =>
bodyTouched.value && !bodyValid.value
? 'Add a first paragraph. It can be a single sentence.'
? t('Add a first paragraph. It can be a single sentence.')
: '',
)
@@ -56,10 +58,10 @@ const showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
return t("Something in the form isn't valid. Check the highlighted fields.")
}
if (formAlert.value === 'failed') {
return "Couldn't create the article. Your text is still here. Try again in a moment."
return t("Couldn't create the article. Your text is still here. Try again in a moment.")
}
return ''
})
@@ -78,7 +80,9 @@ function resetForm() {
async function focusTitleField() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>(`input[placeholder="${TITLE_PLACEHOLDER}"]`)?.focus()
document
.querySelector<HTMLInputElement>(`input[placeholder="${t(TITLE_PLACEHOLDER)}"]`)
?.focus()
titleFocused.value = true
}
@@ -116,7 +120,7 @@ async function onSubmit() {
formAlert.value = ''
submitting.value = true
try {
const article = await api.request<Article>('/v1/article/', {
const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }),
@@ -147,8 +151,8 @@ function onCancel() {
<template>
<UModal
v-model:open="open"
title="New article"
description="It's saved as a draft. Titles can't be changed later, so check it now."
:title="t('New article')"
:description="t('It\'s saved as a draft. Titles can\'t be changed later, so check it now.')"
:dismissible="!submitting"
:ui="{
content:
@@ -173,7 +177,11 @@ function onCancel() {
:description="alertMessage"
/>
<UForm :state="{ title, body }" class="flex flex-col gap-4">
<UFormField label="Title" name="title" :error="titleError || undefined">
<UFormField
:label="t('Title')"
name="title"
:error="translate(titleError) || undefined"
>
<template #hint>
<CharacterCounter
:value="titleLength"
@@ -188,13 +196,15 @@ function onCancel() {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else-if="showHelp" #help>3 to 200 characters.</template>
<template v-else-if="showHelp" #help>{{
t('3 to 200 characters.')
}}</template>
<UInput
v-model="title"
:placeholder="TITLE_PLACEHOLDER"
:placeholder="t(TITLE_PLACEHOLDER)"
:disabled="submitting"
color="primary"
:highlight="titleFocused || !!titleError"
@@ -204,22 +214,26 @@ function onCancel() {
@blur="onTitleBlur"
/>
</UFormField>
<UFormField label="First paragraph" name="body" :error="bodyError || undefined">
<UFormField
:label="t('First paragraph')"
name="body"
:error="bodyError || undefined"
>
<template v-if="bodyError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else-if="showHelp" #help>
You can format it and add images in the editor.
{{ t('You can format it and add images in the editor.') }}
</template>
<UTextarea
v-model="body"
placeholder="Start writing. You'll continue in the editor."
:placeholder="t('Start writing. You\'ll continue in the editor.')"
:disabled="submitting"
:highlight="!!bodyError"
:rows="3"
@@ -244,7 +258,7 @@ function onCancel() {
:disabled="submitting"
@click="onCancel"
>
Cancel
{{ t('Cancel') }}
</UButton>
<UButton
block
@@ -254,7 +268,7 @@ function onCancel() {
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Creating…' : 'Create draft' }}
{{ submitting ? t('Creating…') : t('Create draft') }}
</UButton>
</div>
</template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { translate } from '@/i18n'
import { onMounted, onUnmounted, ref } from 'vue'
const DEFAULT_MESSAGE =
@@ -43,7 +45,7 @@ onUnmounted(() => {
v-if="isOffline"
color="neutral"
:icon="icon"
:title="message"
:title="translate(message)"
:ui="bleed ? BLEED_UI : undefined"
/>
</template>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
interface PasswordRule {
@@ -47,7 +48,7 @@ const items = computed(() =>
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
<span class="sr-only">{{ t(item.met ? 'met' : 'not met') }}</span>
</li>
</ul>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = withDefaults(
@@ -23,12 +25,12 @@ const label = computed(() => {
return props.pending
}
if (props.online) {
return 'Unpublish'
return t('Unpublish')
}
if (props.dirty) {
return 'Save and publish'
return t('Save and publish')
}
return 'Publish'
return t('Publish')
})
const icon = computed(() => {
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
defineProps<{
@@ -23,7 +25,7 @@ const open = ref(false)
class="min-h-11 self-start gap-1.5 p-0 font-medium text-highlighted md:min-h-8"
:ui="{ leadingIcon: 'size-3.5' }"
>
What's new in {{ version }}
{{ t("What's new in") }} {{ version }}
</UButton>
<template #content>
+19 -10
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { MediaItem } from '@/composables/useMedia'
@@ -18,10 +20,10 @@ const stem = ref(parts.stem)
const touched = ref(false)
const error = computed(() =>
touched.value && !isValidFilenameStem(stem.value) ? INVALID_FILENAME_MESSAGE : undefined,
touched.value && !isValidFilenameStem(stem.value) ? t(INVALID_FILENAME_MESSAGE) : undefined,
)
const help = computed(
() => `Letters, numbers, dashes and underscores. The ${parts.extension} ending stays.`,
const help = computed(() =>
t('Letters, numbers, dashes and underscores. The {0} ending stays.', [parts.extension]),
)
async function submit() {
@@ -35,11 +37,15 @@ async function submit() {
<template>
<ConfirmDialog
title="Rename image"
description="Only the filename changes. The URL stays the same, so articles that use this image keep working."
confirm-label="Rename"
cancel-label="Cancel"
pending-label="Renaming…"
:title="t('Rename image')"
:description="
t(
'Only the filename changes. The URL stays the same, so articles that use this image keep working.',
)
"
:confirm-label="t('Rename')"
:cancel-label="t('Cancel')"
:pending-label="t('Renaming…')"
tone="neutral"
:action="submit"
@close="emit('close', $event)"
@@ -47,11 +53,14 @@ async function submit() {
<template #body="{ pending, confirm }">
<form class="flex flex-col" novalidate @submit.prevent="confirm">
<UFormField
label="Filename"
:label="t('Filename')"
name="filename"
:error="error"
:help="help"
:ui="{ error: 'mt-1.5 text-xs/5', help: 'mt-1.5 text-xs/5' }"
:ui="{
error: 'mt-1.5 text-xs/5',
help: 'mt-1.5 text-xs/5',
}"
>
<template v-if="error" #error="{ error: message }">
<span class="flex items-center gap-1.5">
+28 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import type { RestartKind } from '@/composables/useUpdate'
@@ -21,19 +23,37 @@ const emit = defineEmits<{
const copy = computed(() => {
if (props.slow) {
return {
title: 'Still restarting',
text: 'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.',
get title() {
return t('Still restarting')
},
get text() {
return t(
'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.',
)
},
}
}
if (props.kind === 'update') {
return {
title: `Installing version ${props.version}`,
text: 'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.',
get title() {
return t('Installing version {0}', [props.version])
},
get text() {
return t(
'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.',
)
},
}
}
return {
title: 'Restarting your site',
text: 'This takes about 10 seconds. This page reloads on its own when the site is back.',
get title() {
return t('Restarting your site')
},
get text() {
return t(
'This takes about 10 seconds. This page reloads on its own when the site is back.',
)
},
}
})
@@ -62,7 +82,7 @@ const meta = computed(() =>
</div>
<div
role="progressbar"
aria-label="Restarting"
:aria-label="t('Restarting')"
class="h-1 w-full overflow-hidden bg-accented"
>
<div class="restart-bar h-1 w-7/20 bg-primary" />
@@ -75,7 +95,7 @@ const meta = computed(() =>
class="h-11 gap-1.5 border border-accented bg-default px-3.5 text-highlighted ring-0 md:h-8"
@click="emit('reload')"
>
Reload page
{{ t('Reload page') }}
</UButton>
</div>
</div>
+5 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
@@ -17,8 +18,10 @@ const roleClass = computed(() =>
color="neutral"
variant="soft"
:class="roleClass"
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium' }"
:ui="{
base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium',
}"
>
{{ role }}
{{ t(role) }}
</UBadge>
</template>
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more