diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go
index d33469c..0ce6c8d 100644
--- a/api/dependecyInjection.go
+++ b/api/dependecyInjection.go
@@ -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),
diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go
index 6d5973c..f84e361 100644
--- a/api/handlers/article/articleHandler.go
+++ b/api/handlers/article/articleHandler.go
@@ -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
diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go
index 75d144d..18b684f 100644
--- a/api/pkg/apperr/messages.go
+++ b/api/pkg/apperr/messages.go
@@ -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."
diff --git a/docs/api/articles.md b/docs/api/articles.md
new file mode 100644
index 0000000..e80c2d3
--- /dev/null
+++ b/docs/api/articles.md
@@ -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
+
+
+A **centered** paragraph
+
+
+```
+
+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 `Plan
Replace the old deck.
`:
+
+```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 |
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 34886ae..9bce210 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -29,3 +29,4 @@ Each body lives in `docs/releases/.md`. Publish with `tea releases create -
| `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 |
diff --git a/docs/releases/v2.7.0-beta.md b/docs/releases/v2.7.0-beta.md
new file mode 100644
index 0000000..79de0d5
--- /dev/null
+++ b/docs/releases/v2.7.0-beta.md
@@ -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.
diff --git a/features/article_body_format.feature b/features/article_body_format.feature
new file mode 100644
index 0000000..416c60d
--- /dev/null
+++ b/features/article_body_format.feature
@@ -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 "Plan
Replace the old deck.
"
+
+ Scenario Outline: Single read negotiates the body format
+ When an anonymous client sends GET /v1/article/1
+ Then the response status is 200
+ And "body" is
+ And "body_format" is ""
+
+ 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 | "Plan
Replace the old deck.
" | html |
+ | ?format=HTML | "Plan
Replace the old deck.
" | 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 "Second
"
+ 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
+ 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 with
+ Then the response status is
+ And "body_format" is ""
+ 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":"A
"} | 201 | markdown |
+ | POST | /v1/article/?format=html | {"title":"Deck","body":"A
"} | 201 | html |
+ | POST | /v1/article/?format=json | {"title":"Deck","body":"A
"} | 201 | json |
+ | PATCH | /v1/article/1 | {"body":"B
"} | 200 | markdown |
+ | PATCH | /v1/article/1?format=json | {"body":"B
"} | 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":"B
"}
+ 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":"A
"}
+ 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
+ When converted with the format "markdown"
+ Then the body is
+
+ Examples:
+ | html | markdown |
+ | T
S
| # T\n\n### S |
+ | b i
| **b** *i* |
+ | link
| [link](https://example.com/x) |
+ | | - a\n- b |
+ | a
b
| 1. a\n2. b |
+ | | - a |
+ | q
| > q |
+ | x := 1
| ```go\nx := 1\n``` |
+ |
| --- |
+ |
|  |
+ | ok
| ok |
+ | xy
| xy |
+ | use go test
| use `go test` |
+
+ Scenario Outline: Json block shapes, one row per block and mark type
+ Given the stored body is
+ When converted with the format "json"
+ Then the body is
+
+ Examples:
+ | html | json |
+ | T
| [{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}] |
+ | S
| [{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}] |
+ | plain
| [{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}] |
+ | | [{"type":"paragraph","children":[]}] |
+ | b
| [{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}] |
+ | i
| [{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}] |
+ | l
| [{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}] |
+ | x
| [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}] |
+ | a
b
| [{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}] |
+ | | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}] |
+ | a
| [{"type":"list","ordered":true,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}]]}] |
+ | | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"bare","marks":[]}]}]]}] |
+ | | a list item whose blocks are the paragraph "a" followed by a nested list block with ordered false and the item "n" |
+ | | the same output as without data-indent |
+ | q
| [{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}] |
+ | x := 1
| [{"type":"code","language":"go","text":"x := 1"}] |
+ | raw
| [{"type":"code","language":"","text":"raw"}] |
+ |
| [{"type":"hr"}] |
+ |
| [{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}] |
+ |
| [{"type":"image","src":"/static/uploadedImages/a.png","alt":""}] |
+ | x
y
| [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}] |
+ | xy
| [{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}] |
+ | use go test
| [{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}] |
+ | ok
| [{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}] |
+
+ Scenario Outline: Text alignment survives the conversion
+ Given the stored body is
+ When converted with the format "markdown"
+ Then the body is
+ When converted with the format "json"
+ Then the body is
+
+ Examples:
+ | html | markdown | json |
+ | a
|
+
+a
+
+
| [{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}] |
+ | T
|
+
+## T
+
+
| [{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}] |
+ | a
|
+
+a
+
+
| [{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}] |
+ | a
| a | [{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}] |
+ | a
| a | the same output as the left row |
+ | | an empty string | [{"type":"paragraph","align":"center","children":[]}] |
+
+ Scenario Outline: Plain text that looks like markdown stays plain text
+ Given the stored body is
+ When converted with the format "markdown"
+ Then the body is
+
+ Examples:
+ | html | markdown |
+ | # not a heading
| \# not a heading |
+ | #tag
| #tag |
+ | - not a list
| \- not a list |
+ | + not a list
| \+ not a list |
+ | > not a quote
| \> not a quote |
+ | 1. not a list
| 1\. not a list |
+ | 2) not a list
| 2\) not a list |
+ | ---
| \--- |
+ | a - b
| a - b |
+ | a * b _c_
| a \* b \_c\_ |
+ | a
- b
| 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
diff --git a/features/article_body_format.qa.md b/features/article_body_format.qa.md
new file mode 100644
index 0000000..599e15f
--- /dev/null
+++ b/features/article_body_format.qa.md
@@ -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.
diff --git a/go.mod b/go.mod
index 65ba77a..7020b8e 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index de28d55..95093f9 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/internal/domain/article/bodyFormat.go b/internal/domain/article/bodyFormat.go
new file mode 100644
index 0000000..5170c7f
--- /dev/null
+++ b/internal/domain/article/bodyFormat.go
@@ -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)
+}
diff --git a/internal/infrastructure/markdown/blocks.go b/internal/infrastructure/markdown/blocks.go
new file mode 100644
index 0000000..477de51
--- /dev/null
+++ b/internal/infrastructure/markdown/blocks.go
@@ -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
+}
diff --git a/internal/infrastructure/markdown/converter.go b/internal/infrastructure/markdown/converter.go
new file mode 100644
index 0000000..166fac2
--- /dev/null
+++ b/internal/infrastructure/markdown/converter.go
@@ -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
+}
diff --git a/internal/infrastructure/markdown/jsonBlocks.go b/internal/infrastructure/markdown/jsonBlocks.go
new file mode 100644
index 0000000..2bbed51
--- /dev/null
+++ b/internal/infrastructure/markdown/jsonBlocks.go
@@ -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
+}
diff --git a/internal/infrastructure/markdown/markdownRender.go b/internal/infrastructure/markdown/markdownRender.go
new file mode 100644
index 0000000..2eb59d9
--- /dev/null
+++ b/internal/infrastructure/markdown/markdownRender.go
@@ -0,0 +1,213 @@
+package markdown
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+const (
+ blockSeparator = "\n\n"
+ hardBreak = "\\\n"
+ bulletMarker = "- "
+ codeFence = "```"
+ alignOpen = ``
+ alignClose = "
"
+)
+
+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 " + ")"
+ }
+ 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)
+}
diff --git a/internal/infrastructure/useCases/CreateArticle.go b/internal/infrastructure/useCases/CreateArticle.go
index 35c7ab4..3272ee1 100644
--- a/internal/infrastructure/useCases/CreateArticle.go
+++ b/internal/infrastructure/useCases/CreateArticle.go
@@ -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)
}
diff --git a/internal/infrastructure/useCases/GetArticle.go b/internal/infrastructure/useCases/GetArticle.go
index 72b150b..e4b05f2 100644
--- a/internal/infrastructure/useCases/GetArticle.go
+++ b/internal/infrastructure/useCases/GetArticle.go
@@ -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) {
diff --git a/internal/infrastructure/useCases/ListArticles.go b/internal/infrastructure/useCases/ListArticles.go
index 9bba621..a3e63e8 100644
--- a/internal/infrastructure/useCases/ListArticles.go
+++ b/internal/infrastructure/useCases/ListArticles.go
@@ -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)
}
diff --git a/internal/infrastructure/useCases/UpdateArticle.go b/internal/infrastructure/useCases/UpdateArticle.go
index db3dec7..629c1e8 100644
--- a/internal/infrastructure/useCases/UpdateArticle.go
+++ b/internal/infrastructure/useCases/UpdateArticle.go
@@ -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)
}
diff --git a/internal/infrastructure/useCases/renderArticleBody.go b/internal/infrastructure/useCases/renderArticleBody.go
new file mode 100644
index 0000000..f2b3f27
--- /dev/null
+++ b/internal/infrastructure/useCases/renderArticleBody.go
@@ -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
+}
diff --git a/test/article_body_format_test.go b/test/article_body_format_test.go
new file mode 100644
index 0000000..41e2dc7
--- /dev/null
+++ b/test/article_body_format_test.go
@@ -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 = "Plan
Replace the old deck.
"
+ 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: "Draft
", IsOnline: false}
+ repo.articles[3] = domainArticle.Article{ID: 3, Title: "Second article", Body: "Second
", 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", "Second
"},
+ {"?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": "A
", "json": paragraph("A")}
+ bodiesB := expectedBodies{"markdown": "B", "html": "B
", "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":"A
"}`, 201, "markdown", bodiesA},
+ {http.MethodPost, "/v1/article/?format=html", `{"title":"Deck","body":"A
"}`, 201, "html", bodiesA},
+ {http.MethodPost, "/v1/article/?format=json", `{"title":"Deck","body":"A
"}`, 201, "json", bodiesA},
+ {http.MethodPatch, "/v1/article/1", `{"body":"B
"}`, 200, "markdown", bodiesB},
+ {http.MethodPatch, "/v1/article/1?format=json", `{"body":"B
"}`, 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":"B
"}`},
+ {http.MethodPost, "/v1/article/?format=pdf", `{"title":"Deck","body":"A
"}`},
+ {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)
+ }
+ }
+}
diff --git a/test/article_publishing_http_test.go b/test/article_publishing_http_test.go
index 458424b..9ea534d 100644
--- a/test/article_publishing_http_test.go
+++ b/test/article_publishing_http_test.go
@@ -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,
)
diff --git a/test/article_test.go b/test/article_test.go
index 532cd0c..cb0bb28 100644
--- a/test/article_test.go
+++ b/test/article_test.go
@@ -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")
}
diff --git a/test/article_title_rules_test.go b/test/article_title_rules_test.go
index 534ab53..fa2f03e 100644
--- a/test/article_title_rules_test.go
+++ b/test/article_title_rules_test.go
@@ -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"})
diff --git a/test/articles_list_http_test.go b/test/articles_list_http_test.go
index 5c579db..00c7f49 100644
--- a/test/articles_list_http_test.go
+++ b/test/articles_list_http_test.go
@@ -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,
)
diff --git a/test/body_converter_documents_test.go b/test/body_converter_documents_test.go
new file mode 100644
index 0000000..f2babd9
--- /dev/null
+++ b/test/body_converter_documents_test.go
@@ -0,0 +1,78 @@
+package test
+
+import (
+ domainArticle "RenewCMS/internal/domain/article"
+ "RenewCMS/internal/infrastructure/markdown"
+ "encoding/json"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+const hostileBody = `Title
Intro bold
kept

a
`
+
+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{""} {
+ 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", "x
", "```\nx\n```"},
+ {"skipped element dropped", "ab
", "```\nab\n```"},
+ {"style dropped", "a
", "```\na\n```"},
+ {"break becomes newline", "a
b
", "```\na\nb\n```"},
+ {"nested element text kept", "abc
", "```\nabc\n```"},
+ {"trailing newline trimmed", "a\n
", "```\na\n```"},
+ {"fence grows around backticks", "```\nx
", "````\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)
+ }
+ })
+ }
+}
diff --git a/test/edit_article_body_http_test.go b/test/edit_article_body_http_test.go
index e4af86b..ea0851b 100644
--- a/test/edit_article_body_http_test.go
+++ b/test/edit_article_body_http_test.go
@@ -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,
)
diff --git a/test/json_blocks_converter_test.go b/test/json_blocks_converter_test.go
new file mode 100644
index 0000000..386f6ad
--- /dev/null
+++ b/test/json_blocks_converter_test.go
@@ -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", "T
", `[{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}]`},
+ {"h6", "S
", `[{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}]`},
+ {"paragraph", "plain
", `[{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}]`},
+ {"empty paragraph", "", `[{"type":"paragraph","children":[]}]`},
+ {"bold", "b
", `[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}]`},
+ {"italic", "i
", `[{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}]`},
+ {"link", `l
`, `[{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}]`},
+ {"nested marks outermost first", `x
`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}]`},
+ {"line break", "a
b
", `[{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}]`},
+ {"bullet list", "", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}]`},
+ {"ordered list", "a
", `[{"type":"list","ordered":true,"items":[[` + paragraphA + `]]}]`},
+ {"bare list item", "", listOfA},
+ {"nested list", "", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `,{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"n","marks":[]}]}]]}]]}]`},
+ {"data-indent ignored", ``, listOfA},
+ {"blockquote", "q
", `[{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}]`},
+ {"code with language", `x := 1
`, `[{"type":"code","language":"go","text":"x := 1"}]`},
+ {"code without language", "raw
", `[{"type":"code","language":"","text":"raw"}]`},
+ {"rule", "
", `[{"type":"hr"}]`},
+ {"image with alt", `
`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}]`},
+ {"image without alt", `
`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":""}]`},
+ {"image splits paragraph", `x
y
`, `[{"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", `xy
`, `[{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}]`},
+ {"inline code degrades", "use go test
", `[{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}]`},
+ {"script and style removed", "ok
", `[{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}]`},
+ {"empty html", "", `[]`},
+ {"centered paragraph", `a
`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}]`},
+ {"right aligned heading", `T
`, `[{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}]`},
+ {"justified paragraph", `a
`, `[{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}]`},
+ {"left alignment omitted", `a
`, `[` + paragraphA + `]`},
+ {"alignment skips the image inside a paragraph", `x
y
`, `[{"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)
+ }
+ })
+ }
+}
diff --git a/test/markdown_converter_test.go b/test/markdown_converter_test.go
new file mode 100644
index 0000000..63ca097
--- /dev/null
+++ b/test/markdown_converter_test.go
@@ -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", "T
S
", "# T\n\n### S"},
+ {"bold and italic", "b i
", "**b** *i*"},
+ {"link", `link
`, "[link](https://example.com/x)"},
+ {"bullet list", "", "- a\n- b"},
+ {"ordered list", "a
b
", "1. a\n2. b"},
+ {"data-indent dropped", ``, "- a"},
+ {"nested list", "", "- a\n - n"},
+ {"blockquote", "q
", "> q"},
+ {"code block", `x := 1
`, "```go\nx := 1\n```"},
+ {"rule", "
", "---"},
+ {"image", `
`, ""},
+ {"script and style removed", "ok
", "ok"},
+ {"unsupported span stripped", `xy
`, "xy"},
+ {"inline code", "use go test
", "use `go test`"},
+ {"empty body", "", ""},
+ {"line break", "a
b
", "a\\\nb"},
+ {"literal angle bracket is escaped", "<script>
", "\\", "```\na\nb\n```"},
+ {"adjacent same marks merge", "ab
", "**ab**"},
+ {"adjacent different marks stay apart", "ab
", "**a***b*"},
+ {"two paragraphs", "a
b
", "a\n\nb"},
+ {"centered paragraph", `hi b
`, "\n\nhi **b**\n\n
"},
+ {"right aligned heading", `T
`, "\n\n## T\n\n
"},
+ {"justified among other styles", `a
`, "\n\na\n\n
"},
+ {"tiptap style with trailing semicolon", `a
`, "\n\na\n\n
"},
+ {"tiptap explicit left", `a
`, "a"},
+ {"left alignment is the default", `a
`, "a"},
+ {"unknown alignment ignored", `a
`, "a"},
+ {"style without value ignored", `a
`, "a"},
+ {"empty aligned paragraph", ``, ""},
+ {"aligned paragraph in list item", ``, "- \n\n a\n\n
"},
+ {"aligned paragraph in blockquote", `q
`, "> \n>\n> q\n>\n>
"},
+ {"alignment skips the image inside a paragraph", `x
y
`, "\n\nx\n\n
\n\n\n\n\n\ny\n\n
"},
+ {"text characters are escaped", "a * b _c_ `d` [e]
", "a \\* b \\_c\\_ \\`d\\` \\[e\\]"},
+ {"leading hash is escaped", "# not a heading
", "\\# not a heading"},
+ {"hash without space is kept", "#tag
", "#tag"},
+ {"leading dash is escaped", "- not a list
", "\\- not a list"},
+ {"leading plus is escaped", "+ not a list
", "\\+ not a list"},
+ {"leading quote marker is escaped", "> not a quote
", "\\> not a quote"},
+ {"leading number is escaped", "1. not a list
", "1\\. not a list"},
+ {"leading number with parenthesis is escaped", "2) not a list
", "2\\) not a list"},
+ {"leading rule is escaped", "---
", "\\---"},
+ {"leading equals is escaped", "==
", "\\=="},
+ {"inner dash is kept", "a - b
", "a - b"},
+ {"marker after a line break is escaped", "a
- b
", "a\\\n\\- b"},
+ {"combined document", `Title
Intro bold and it

x := 1
quote
`, "# Title\n\n\n\nIntro **bold** and *it*\n\n
\n\n- one\n- two\n\n\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 := "a
"
+ 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)
+ }
+}
diff --git a/test/media_env_test.go b/test/media_env_test.go
index df34bf3..77e6cfa 100644
--- a/test/media_env_test.go
+++ b/test/media_env_test.go
@@ -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),
diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go
index 30800aa..b8dd25c 100644
--- a/test/not_found_errors_test.go
+++ b/test/not_found_errors_test.go
@@ -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()
diff --git a/test/public_article_listing_test.go b/test/public_article_listing_test.go
index 91a408f..071b825 100644
--- a/test/public_article_listing_test.go
+++ b/test/public_article_listing_test.go
@@ -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,
)
diff --git a/test/public_article_read_test.go b/test/public_article_read_test.go
index f461910..36996f7 100644
--- a/test/public_article_read_test.go
+++ b/test/public_article_read_test.go
@@ -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,
)
diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go
index d7a31df..f8d4677 100644
--- a/test/runtime_settings_injection_http_test.go
+++ b/test/runtime_settings_injection_http_test.go
@@ -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"),
diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go
index 6559fbd..e9f1379 100644
--- a/test/stale_token_invalidation_http_test.go
+++ b/test/stale_token_invalidation_http_test.go
@@ -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(
diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go
index 7286799..09ff2da 100644
--- a/test/use_case_baseline_test.go
+++ b/test/use_case_baseline_test.go
@@ -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)
}
diff --git a/test/verified_account_required_http_test.go b/test/verified_account_required_http_test.go
index fa7a437..c24acf6 100644
--- a/test/verified_account_required_http_test.go
+++ b/test/verified_account_required_http_test.go
@@ -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,
)
diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go
index 6364d57..b32198b 100644
--- a/test/write_not_found_errors_test.go
+++ b/test/write_not_found_errors_test.go
@@ -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,
)
diff --git a/web/bun.lock b/web/bun.lock
index ac5601d..ff1b2c8 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -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=="],
diff --git a/web/package.json b/web/package.json
index 29cbc8f..8d54e3d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/src/components/ArticleEditor.vue b/web/src/components/ArticleEditor.vue
index 4536a67..7771a28 100644
--- a/web/src/components/ArticleEditor.vue
+++ b/web/src/components/ArticleEditor.vue
@@ -5,6 +5,7 @@ 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(
@@ -27,6 +28,7 @@ const emit = defineEmits<{
const editorInstance = ref(null)
const extensions = [
ArticleIndent,
+ ArticleTextAlign,
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
diff --git a/web/src/components/NewArticleModal.vue b/web/src/components/NewArticleModal.vue
index 74fdfec..b319bc3 100644
--- a/web/src/components/NewArticleModal.vue
+++ b/web/src/components/NewArticleModal.vue
@@ -120,7 +120,7 @@ async function onSubmit() {
formAlert.value = ''
submitting.value = true
try {
- const article = await api.request('/v1/article/', {
+ const article = await api.request('/v1/article/?format=html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }),
diff --git a/web/src/components/__tests__/ArticleEditor.spec.ts b/web/src/components/__tests__/ArticleEditor.spec.ts
index 83635c2..60ef929 100644
--- a/web/src/components/__tests__/ArticleEditor.spec.ts
+++ b/web/src/components/__tests__/ArticleEditor.spec.ts
@@ -56,6 +56,35 @@ afterEach(async () => {
await router.push('/login')
})
+describe('the alignment toolbar buttons align the current block', () => {
+ const ALIGNMENTS = ['left', 'center', 'right', 'justify']
+ const FIRST_ALIGN_BUTTON = 7
+
+ function alignButton(name: string) {
+ const buttons = document.body.querySelectorAll('.article-editor button')
+ return buttons[FIRST_ALIGN_BUTTON + ALIGNMENTS.indexOf(name)] ?? null
+ }
+
+ it.each(ALIGNMENTS)('keeps the %s button enabled', async (name) => {
+ await mountArticleEditor()
+
+ const button = alignButton(name)
+
+ expect(button).not.toBeNull()
+ expect(button?.disabled).toBe(false)
+ })
+
+ it('writes the alignment into the body when a button is clicked', async () => {
+ const wrapper = await mountArticleEditor()
+ const editor = wrapper.findComponent(ArticleEditor)
+
+ alignButton('center')?.click()
+ await flushPromises()
+
+ expect(editor.props('modelValue')).toContain('text-align: center')
+ })
+})
+
describe('insertImage stamps the inserted image with its numeric id', () => {
it('renders data-image-id on the img and emits the updated body', async () => {
const wrapper = await mountArticleEditor()
diff --git a/web/src/components/__tests__/NewArticleModal.spec.ts b/web/src/components/__tests__/NewArticleModal.spec.ts
index ebe5749..0016061 100644
--- a/web/src/components/__tests__/NewArticleModal.spec.ts
+++ b/web/src/components/__tests__/NewArticleModal.spec.ts
@@ -206,7 +206,7 @@ describe('Submitting disables the form and cannot be dismissed', () => {
await clickButton('Create draft')
expect(requestMock).toHaveBeenCalledWith(
- '/v1/article/',
+ '/v1/article/?format=html',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: TITLE, body: BODY }),
@@ -336,7 +336,7 @@ describe('Retrying after an Error succeeds', () => {
expect(requestMock).toHaveBeenCalledTimes(2)
expect(requestMock).toHaveBeenNthCalledWith(
2,
- '/v1/article/',
+ '/v1/article/?format=html',
expect.objectContaining({
body: JSON.stringify({ title: TITLE, body: BODY }),
}),
diff --git a/web/src/composables/__tests__/useArticles.spec.ts b/web/src/composables/__tests__/useArticles.spec.ts
new file mode 100644
index 0000000..023d846
--- /dev/null
+++ b/web/src/composables/__tests__/useArticles.spec.ts
@@ -0,0 +1,23 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const request = vi.fn()
+
+vi.mock('@/composables/useApi', () => ({
+ useApi: () => ({ request }),
+}))
+
+import { useArticles } from '@/composables/useArticles'
+
+describe('useArticles', () => {
+ beforeEach(() => {
+ request.mockReset()
+ })
+
+ it('asks the API for html bodies when listing articles', async () => {
+ request.mockResolvedValue([])
+
+ await useArticles().listArticles()
+
+ expect(request).toHaveBeenCalledWith('/v1/article/?format=html')
+ })
+})
diff --git a/web/src/composables/useArticleDraft.ts b/web/src/composables/useArticleDraft.ts
index 8d7cdaa..63f03bb 100644
--- a/web/src/composables/useArticleDraft.ts
+++ b/web/src/composables/useArticleDraft.ts
@@ -79,7 +79,7 @@ export function useArticleDraft(articleId: number) {
async function save(): Promise {
businessState.value = 'saving'
try {
- await request(`/v1/article/${articleId}`, {
+ await request(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: body.value }),
diff --git a/web/src/composables/useArticles.ts b/web/src/composables/useArticles.ts
index 906ba3a..cff0cc9 100644
--- a/web/src/composables/useArticles.ts
+++ b/web/src/composables/useArticles.ts
@@ -13,7 +13,7 @@ export function useArticles() {
const { request } = useApi()
function listArticles(): Promise {
- return request('/v1/article/')
+ return request('/v1/article/?format=html')
}
return { listArticles }
diff --git a/web/src/i18n/spa.spec.ts b/web/src/i18n/spa.spec.ts
index d255f98..0c9b1b5 100644
--- a/web/src/i18n/spa.spec.ts
+++ b/web/src/i18n/spa.spec.ts
@@ -393,7 +393,7 @@ describe('Hardener localization outcomes', () => {
]).toEqual([window.location.origin, true, true, true])
await vi.waitFor(() =>
expect(document.body.textContent).toContain(
- `const res = await fetch('${window.location.origin}/v1/article/')`,
+ `const res = await fetch('${window.location.origin}/v1/article/?format=html')`,
),
)
vi.unstubAllGlobals()
@@ -411,7 +411,7 @@ describe('Hardener localization outcomes', () => {
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
- url === '/v1/article/' ? [article] : article,
+ url === '/v1/article/?format=html' ? [article] : article,
)
await mountRoute(path)
if (path === '/article') {
diff --git a/web/src/lib/__tests__/articleAlign.spec.ts b/web/src/lib/__tests__/articleAlign.spec.ts
new file mode 100644
index 0000000..14705fc
--- /dev/null
+++ b/web/src/lib/__tests__/articleAlign.spec.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import { Editor } from '@tiptap/core'
+import StarterKit from '@tiptap/starter-kit'
+import { ArticleIndent } from '../articleIndent'
+import { ArticleTextAlign } from '../articleAlign'
+
+function createEditor(content: string) {
+ return new Editor({
+ extensions: [StarterKit, ArticleIndent, ArticleTextAlign],
+ content,
+ })
+}
+
+describe('Aligning text writes text-align on paragraphs and headings', () => {
+ it.each(['center', 'right', 'justify'])('can align a paragraph to the %s', (align) => {
+ const editor = createEditor('Hello
')
+ editor.commands.setTextSelection(2)
+
+ expect(editor.can().setTextAlign(align)).toBe(true)
+ editor.commands.setTextAlign(align)
+
+ expect(editor.getHTML()).toContain(`Hello
`)
+ expect(editor.isActive({ textAlign: align })).toBe(true)
+ editor.destroy()
+ })
+
+ it('leaves unaligned paragraphs and headings without a style', () => {
+ const editor = createEditor('Title
Hello
')
+
+ expect(editor.getHTML()).toBe('Title
Hello
')
+ editor.destroy()
+ })
+
+ it('aligns a heading', () => {
+ const editor = createEditor('Title
')
+ editor.commands.setTextSelection(2)
+
+ editor.commands.setTextAlign('right')
+
+ expect(editor.getHTML()).toContain('Title
')
+ editor.destroy()
+ })
+
+ it('returns to left alignment', () => {
+ const editor = createEditor('Hello
')
+ editor.commands.setTextSelection(2)
+
+ editor.commands.setTextAlign('left')
+
+ expect(editor.isActive({ textAlign: 'center' })).toBe(false)
+ expect(editor.getHTML()).not.toContain('center')
+ editor.destroy()
+ })
+
+ it('loads stored alignment back from HTML', () => {
+ const editor = createEditor('Hello
')
+ editor.commands.setTextSelection(2)
+
+ expect(editor.isActive({ textAlign: 'justify' })).toBe(true)
+ editor.destroy()
+ })
+
+ it('keeps alignment and indentation together', () => {
+ const editor = createEditor('Hello
')
+ editor.commands.setTextSelection(2)
+
+ editor.commands.indent()
+ editor.commands.setTextAlign('center')
+
+ expect(editor.getHTML()).toContain('data-indent="1"')
+ expect(editor.getHTML()).toContain('text-align: center')
+ editor.destroy()
+ })
+})
diff --git a/web/src/lib/articleAlign.ts b/web/src/lib/articleAlign.ts
new file mode 100644
index 0000000..a380658
--- /dev/null
+++ b/web/src/lib/articleAlign.ts
@@ -0,0 +1,8 @@
+import TextAlign from '@tiptap/extension-text-align'
+
+export const ALIGN_TYPES = ['heading', 'paragraph']
+
+export const ArticleTextAlign = TextAlign.configure({
+ types: ALIGN_TYPES,
+ alignments: ['left', 'center', 'right', 'justify'],
+})
diff --git a/web/src/views/ArticleView.vue b/web/src/views/ArticleView.vue
index 15702a0..e274cf3 100644
--- a/web/src/views/ArticleView.vue
+++ b/web/src/views/ArticleView.vue
@@ -188,7 +188,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true)
try {
const updated = await request(
- `/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}`,
+ `/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
articles.value = articles.value.map((existing) =>
diff --git a/web/src/views/EditorView.vue b/web/src/views/EditorView.vue
index 422a95a..7de5160 100644
--- a/web/src/views/EditorView.vue
+++ b/web/src/views/EditorView.vue
@@ -65,7 +65,7 @@ async function loadArticle() {
}, SKELETON_DELAY_MS)
try {
- const result = await request(`/v1/article/${articleId}`)
+ const result = await request(`/v1/article/${articleId}?format=html`)
article.value = result
draft.load(result.body, new Date(result.updated_at))
loadState.value = 'loaded'
@@ -128,7 +128,7 @@ async function setOnline(makeOnline: boolean) {
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
try {
const updated = await request(
- `/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
+ `/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
article.value = updated
diff --git a/web/src/views/IntegrationView.vue b/web/src/views/IntegrationView.vue
index d2faa30..54b8089 100644
--- a/web/src/views/IntegrationView.vue
+++ b/web/src/views/IntegrationView.vue
@@ -32,6 +32,7 @@ const sampleArticle = {
updated_at: '2026-03-12T09:14:00Z',
},
],
+ body_format: 'html',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
@@ -47,8 +48,8 @@ const endpoints = computed(() => [
return t('Lists every online article.')
},
examples: {
- fetch: `const res = await fetch('${origin.value}/v1/article/')\nconst articles = await res.json()`,
- curl: `curl ${origin.value}/v1/article/`,
+ fetch: `const res = await fetch('${origin.value}/v1/article/?format=html')\nconst articles = await res.json()`,
+ curl: `curl ${origin.value}/v1/article/?format=html`,
response: JSON.stringify(sampleArticleList, null, 2),
},
},
@@ -61,8 +62,8 @@ const endpoints = computed(() => [
)
},
examples: {
- fetch: `const res = await fetch('${origin.value}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
- curl: `curl ${origin.value}/v1/article/12`,
+ fetch: `const res = await fetch('${origin.value}/v1/article/12?format=html')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
+ curl: `curl ${origin.value}/v1/article/12?format=html`,
response: JSON.stringify(sampleArticle, null, 2),
},
},
diff --git a/web/src/views/__tests__/ArticleView.spec.ts b/web/src/views/__tests__/ArticleView.spec.ts
index ee31090..393f92a 100644
--- a/web/src/views/__tests__/ArticleView.spec.ts
+++ b/web/src/views/__tests__/ArticleView.spec.ts
@@ -192,7 +192,7 @@ afterEach(async () => {
describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
expect(wrapper.text()).toContain('All 6')
@@ -218,7 +218,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null
const wrapper = await mountArticles({
- 'GET /v1/article/': () =>
+ 'GET /v1/article/?format=html': () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticleFixture()))
}),
@@ -239,7 +239,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -260,7 +260,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
})
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -287,7 +287,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
})
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -305,7 +305,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, []),
+ 'GET /v1/article/?format=html': () => mockResponse(200, []),
})
expect(wrapper.text()).toContain('All 0')
@@ -326,7 +326,7 @@ describe('Empty — zero articles', () => {
describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(500),
+ 'GET /v1/article/?format=html': () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
@@ -336,7 +336,7 @@ describe('Initial load failure — an error must not render the no-articles empt
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
const searchInput = wrapper.find('input[type="search"]')
@@ -367,7 +367,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, onlineOnly),
+ 'GET /v1/article/?format=html': () => mockResponse(200, onlineOnly),
})
await clickTab('Drafts 0')
@@ -384,7 +384,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, draftsOnly),
+ 'GET /v1/article/?format=html': () => mockResponse(200, draftsOnly),
})
await clickTab('Online 0')
@@ -400,8 +400,8 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
- 'PATCH /v1/article/4/publish': () => new Promise(() => {}),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
+ 'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -420,8 +420,8 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
updated_at: iso(0),
}
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
- 'PATCH /v1/article/4/publish': () => mockResponse(200, updated),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
+ 'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -448,8 +448,8 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
- 'PATCH /v1/article/4/publish': patchCall,
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
+ 'PATCH /v1/article/4/publish?format=html': patchCall,
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -487,8 +487,8 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
- 'PATCH /v1/article/3/unpublish': patchCall,
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
+ 'PATCH /v1/article/3/unpublish?format=html': patchCall,
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
@@ -517,8 +517,8 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
- 'PATCH /v1/article/2/publish': () => mockResponse(404),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
+ 'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
@@ -540,7 +540,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/2': () => mockResponse(404),
})
@@ -565,8 +565,8 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
- 'PATCH /v1/article/3/unpublish': () => mockResponse(404),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
+ 'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
@@ -590,7 +590,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -611,7 +611,7 @@ describe('Delete confirm — opens the dialog with the row title and image count
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(204),
})
@@ -640,7 +640,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({
- 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(500),
})
diff --git a/web/src/views/__tests__/EditorImageUpload.spec.ts b/web/src/views/__tests__/EditorImageUpload.spec.ts
index 266f185..e9933a3 100644
--- a/web/src/views/__tests__/EditorImageUpload.spec.ts
+++ b/web/src/views/__tests__/EditorImageUpload.spec.ts
@@ -191,7 +191,7 @@ const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused:
function articleHandlers(extra: Record Response | Promise> = {}) {
return {
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
...extra,
}
diff --git a/web/src/views/__tests__/EditorMediaPicker.spec.ts b/web/src/views/__tests__/EditorMediaPicker.spec.ts
index c076a32..2d0b3d4 100644
--- a/web/src/views/__tests__/EditorMediaPicker.spec.ts
+++ b/web/src/views/__tests__/EditorMediaPicker.spec.ts
@@ -57,7 +57,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(async (url: string) => {
- if (url === '/v1/article/12') {
+ if (url === '/v1/article/12?format=html') {
return mockResponse(200, articleFixture(body))
}
if (url === '/v1/media/?limit=24') {
diff --git a/web/src/views/__tests__/EditorView.spec.ts b/web/src/views/__tests__/EditorView.spec.ts
index bdef44f..480506c 100644
--- a/web/src/views/__tests__/EditorView.spec.ts
+++ b/web/src/views/__tests__/EditorView.spec.ts
@@ -147,7 +147,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => {
let resolveGet: (() => void) | null = null
const wrapper = await mountEditor({
- 'GET /v1/article/12': () =>
+ 'GET /v1/article/12?format=html': () =>
new Promise((resolve) => {
resolveGet = () => resolve(mockResponse(200, articleFixture()))
}),
@@ -171,7 +171,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())),
+ 'GET /v1/article/12?format=html': () => Promise.resolve(mockResponse(200, articleFixture())),
})
await vi.advanceTimersByTimeAsync(0)
@@ -190,7 +190,7 @@ describe('Saved — a freshly loaded article, draft or online', () => {
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture({ is_online })),
})
expect(wrapper.text()).toContain('Articles')
@@ -207,7 +207,7 @@ describe('Saved — a freshly loaded article, draft or online', () => {
describe('Unsaved changes — any edit marks the article dirty', () => {
it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -222,12 +222,12 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
it('sends the edited body and shows Saving…', async () => {
let patchBody: string | null = null
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
global.fetch = vi.fn((url: string, init?: RequestInit) => {
- if (routeFor(url, init) === 'PATCH /v1/article/12') {
+ if (routeFor(url, init) === 'PATCH /v1/article/12?format=html') {
patchBody = init?.body as string
return new Promise(() => {})
}
@@ -248,13 +248,13 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
it('sends the same PATCH on Ctrl+S', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
let patchBody: string | null = null
global.fetch = vi.fn((url: string, init?: RequestInit) => {
- if (routeFor(url, init) === 'PATCH /v1/article/12') {
+ if (routeFor(url, init) === 'PATCH /v1/article/12?format=html') {
patchBody = init?.body as string
return new Promise(() => {})
}
@@ -276,8 +276,8 @@ describe('A successful save returns to the Saved state with the new time', () =>
vi.setSystemTime(new Date('2026-09-26T11:30:00Z'))
let resolvePatch: (() => void) | null = null
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
- 'PATCH /v1/article/12': () =>
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
+ 'PATCH /v1/article/12?format=html': () =>
new Promise((resolve) => {
resolvePatch = () =>
resolve(
@@ -305,7 +305,7 @@ describe('A successful save returns to the Saved state with the new time', () =>
describe('SaveStatus switches to a relative time after an hour', () => {
it('reads "Saved n h ago" once more than an hour has passed', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
expect(wrapper.text()).toContain('Saved at 11:02')
@@ -327,8 +327,8 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
},
])('shows Save failed with Retry for $name', async ({ handler }) => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
- 'PATCH /v1/article/12': handler,
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
+ 'PATCH /v1/article/12?format=html': handler,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
@@ -354,8 +354,8 @@ describe('Retry resends the same body and can recover', () => {
),
)
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
- 'PATCH /v1/article/12': patchCall,
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
+ 'PATCH /v1/article/12?format=html': patchCall,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
@@ -373,8 +373,8 @@ describe('Retry resends the same body and can recover', () => {
describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => {
it('shows the toast and never reads Save failed', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
- 'PATCH /v1/article/12': () => mockResponse(404),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
+ 'PATCH /v1/article/12?format=html': () => mockResponse(404),
})
await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click')
@@ -392,7 +392,7 @@ describe('A save against an article deleted elsewhere shows a toast, not the err
describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => {
it('shows the warning icon and keeps Save enabled', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(9240))
@@ -405,7 +405,7 @@ describe('Near the limit — the counter warns at 9000 characters without blocki
describe('Too long — over 10000 characters disables Save and Publish until shortened', () => {
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(10214))
@@ -429,7 +429,7 @@ describe('Too long — over 10000 characters disables Save and Publish until sho
describe('Preview shows the rendered title and body without the editing surface', () => {
it('replaces the editor with a read-only rendering, then restores it unchanged', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () =>
+ 'GET /v1/article/12?format=html': () =>
mockResponse(200, articleFixture({ body: 'Some body
' })),
})
@@ -454,7 +454,7 @@ describe('Preview shows the rendered title and body without the editing surface'
describe('Offline blocks Save and Publish, and recovers when the connection returns', () => {
it('shows the offline SaveStatus and re-enables Save once back online', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -478,7 +478,7 @@ describe('Offline blocks Save and Publish, and recovers when the connection retu
describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => {
it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -507,7 +507,7 @@ describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds',
describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => {
it('keeps the user on the editor with the unsaved body intact', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -528,7 +528,7 @@ describe('Leaving with unsaved changes, "Keep editing" stays on the page', () =>
describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => {
it('keeps the user on /article/12/edit with the unsaved body intact', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -549,7 +549,7 @@ describe('Esc or the overlay on the leave guard behaves like "Keep editing"', ()
describe('Leaving a clean article never prompts', () => {
it('navigates directly with no ConfirmDialog', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
@@ -564,8 +564,8 @@ describe('Leaving a clean article never prompts', () => {
describe('Session expired mid-save keeps the draft and restores it after logging back in', () => {
it('mirrors the body to sessionStorage and restores it on the next mount', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleFixture()),
- 'PATCH /v1/article/12': () => mockResponse(401),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
+ 'PATCH /v1/article/12?format=html': () => mockResponse(401),
})
const body = paragraph('Paragraph added just before the session expired.')
await typeBody(wrapper, body)
@@ -581,7 +581,7 @@ describe('Session expired mid-save keeps the draft and restores it after logging
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
- 'GET /v1/article/12': () =>
+ 'GET /v1/article/12?format=html': () =>
mockResponse(
200,
articleFixture({
@@ -624,7 +624,7 @@ function requestCalls(wrapper: Awaited>) {
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
it('opens ConfirmDialog with the exact copy and sends nothing', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
@@ -637,13 +637,13 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.tone).toBe('neutral')
- expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
+ expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
})
it('previews the image the session has seen with its filename and size', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }])
@@ -659,7 +659,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
it('previews only the thumbnail for an image the session has not seen', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
@@ -676,7 +676,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
describe('Cancelling the confirm changes nothing', () => {
it('keeps the image and the draft state when the dialog resolves to cancel', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
@@ -686,14 +686,14 @@ describe('Cancelling the confirm changes nothing', () => {
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
- expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
+ expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
})
})
describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => {
it('removes the image from the content, reads Unsaved changes and sends no request', async () => {
const wrapper = await mountEditor({
- 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
+ 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
@@ -710,7 +710,7 @@ describe('Confirming removes the image from the body only, marks the draft dirty
title: 'Image removed',
description: "It's still in your library.",
})
- expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
+ expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
})
})
@@ -718,7 +718,7 @@ describe('An unknown or deleted article id replaces the editor with an error sta
it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor(
{
- 'GET /v1/article/9999': () => mockResponse(404),
+ 'GET /v1/article/9999?format=html': () => mockResponse(404),
},
'/article/9999/edit',
)
diff --git a/web/src/views/__tests__/HomeUpdateBanner.spec.ts b/web/src/views/__tests__/HomeUpdateBanner.spec.ts
index d227417..83a1734 100644
--- a/web/src/views/__tests__/HomeUpdateBanner.spec.ts
+++ b/web/src/views/__tests__/HomeUpdateBanner.spec.ts
@@ -35,7 +35,7 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
- if (url === '/v1/article/') {
+ if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, []))
}
if (url === '/v1/system/update') {
diff --git a/web/src/views/__tests__/HomeUpdateReady.spec.ts b/web/src/views/__tests__/HomeUpdateReady.spec.ts
index 3e7b46b..6000964 100644
--- a/web/src/views/__tests__/HomeUpdateReady.spec.ts
+++ b/web/src/views/__tests__/HomeUpdateReady.spec.ts
@@ -35,7 +35,7 @@ async function mountHome(role: UserRole, status: UpdateStatus) {
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
- if (url === '/v1/article/') {
+ if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, []))
}
if (url === '/v1/system/update') {
diff --git a/web/src/views/__tests__/HomeView.spec.ts b/web/src/views/__tests__/HomeView.spec.ts
index 7e07c72..8d1de0c 100644
--- a/web/src/views/__tests__/HomeView.spec.ts
+++ b/web/src/views/__tests__/HomeView.spec.ts
@@ -136,7 +136,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
vi.useFakeTimers()
let resolveList: (() => void) | null = null
const wrapper = await mountHome({
- 'GET /v1/article/': () =>
+ 'GET /v1/article/?format=html': () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticles))
}),
@@ -160,7 +160,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
- 'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
+ 'GET /v1/article/?format=html': () => Promise.resolve(mockResponse(200, sixArticles)),
})
await vi.advanceTimersByTimeAsync(0)
@@ -174,7 +174,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
describe('Default — the list loaded with a mix of online and draft articles', () => {
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
const wrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(200, sixArticles),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -217,8 +217,8 @@ describe('A successful create hands off to the editor with a toast', () => {
updated_at: '2026-01-01T00:00:00Z',
}
const wrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(200, sixArticles),
- 'POST /v1/article/': () => mockResponse(201, created),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
+ 'POST /v1/article/?format=html': () => mockResponse(201, created),
})
await findButton(wrapper, 'New article')?.trigger('click')
@@ -258,7 +258,7 @@ describe('A successful create hands off to the editor with a toast', () => {
describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(200, []),
+ 'GET /v1/article/?format=html': () => mockResponse(200, []),
})
expect(wrapper.text()).toContain('Welcome to RenewCMS.')
@@ -284,7 +284,7 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
},
])('shows the alert with identical copy for $name', async ({ handler }) => {
const wrapper = await mountHome({
- 'GET /v1/article/': handler,
+ 'GET /v1/article/?format=html': handler,
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -304,7 +304,7 @@ describe('Retry re-issues the request and recovers into the Default state', () =
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(mockResponse(200, sixArticles))
const wrapper = await mountHome({
- 'GET /v1/article/': listCall,
+ 'GET /v1/article/?format=html': listCall,
})
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
@@ -323,7 +323,7 @@ describe('The list is fetched exactly once per navigation', () => {
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
const listCall = vi.fn(() => mockResponse(200, sixArticles))
const wrapper = await mountHome({
- 'GET /v1/article/': listCall,
+ 'GET /v1/article/?format=html': listCall,
})
expect(listCall).toHaveBeenCalledTimes(1)
@@ -344,7 +344,7 @@ describe('The greeting matches the hour of day', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
const wrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(200, sixArticles),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain(`${greeting}, marta`)
@@ -357,23 +357,23 @@ describe('The greeting is identical across every load state at a fixed hour', ()
vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0))
const loadingWrapper = await mountHome({
- 'GET /v1/article/': () => new Promise(() => {}),
+ 'GET /v1/article/?format=html': () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(loadingWrapper.text()).toContain('Good morning, marta')
const defaultWrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(200, sixArticles),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
})
expect(defaultWrapper.text()).toContain('Good morning, marta')
const emptyWrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(200, []),
+ 'GET /v1/article/?format=html': () => mockResponse(200, []),
})
expect(emptyWrapper.text()).toContain('Good morning, marta')
const errorWrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(500),
+ 'GET /v1/article/?format=html': () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Good morning, marta')
})
@@ -394,7 +394,7 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready'
global.fetch = fetchRouter({
- 'GET /v1/article/': () => mockResponse(200, sixArticles),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -414,7 +414,7 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready'
global.fetch = fetchRouter({
- 'GET /v1/article/': () => mockResponse(200, sixArticles),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -440,7 +440,7 @@ describe('The greeting handles a missing username', () => {
session.session = { verified: true }
session.status = 'ready'
global.fetch = fetchRouter({
- 'GET /v1/article/': () => mockResponse(200, sixArticles),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -461,7 +461,7 @@ describe('The "Connect your website" card renders in every state', () => {
it('renders the same card while loading, in Default, Empty and Error', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
- 'GET /v1/article/': () => new Promise(() => {}),
+ 'GET /v1/article/?format=html': () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.text()).toContain('Connect your website')
@@ -471,17 +471,17 @@ describe('The "Connect your website" card renders in every state', () => {
vi.useRealTimers()
const defaultWrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(200, sixArticles),
+ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
})
expect(defaultWrapper.text()).toContain('Connect your website')
const emptyWrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(200, []),
+ 'GET /v1/article/?format=html': () => mockResponse(200, []),
})
expect(emptyWrapper.text()).toContain('Connect your website')
const errorWrapper = await mountHome({
- 'GET /v1/article/': () => mockResponse(500),
+ 'GET /v1/article/?format=html': () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Connect your website')
})
diff --git a/web/src/views/__tests__/IntegrationView.spec.ts b/web/src/views/__tests__/IntegrationView.spec.ts
index 85fc905..09d886e 100644
--- a/web/src/views/__tests__/IntegrationView.spec.ts
+++ b/web/src/views/__tests__/IntegrationView.spec.ts
@@ -71,12 +71,12 @@ describe("Default — the page loads with the app's own origin", () => {
expect(cardTexts).toHaveLength(2)
expect(cardTexts[0]).toContain('/v1/article/')
expect(cardTexts[0]).toContain('Lists every online article.')
- expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/`)
+ expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/?format=html`)
expect(cardTexts[1]).toContain('/v1/article/{id}')
expect(cardTexts[1]).toContain(
'Returns one online article. Drafts and unknown ids both return 404.',
)
- expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12`)
+ expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12?format=html`)
const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch')
expect(fetchTabs.length).toBeGreaterThan(0)
@@ -138,7 +138,7 @@ describe("Each EndpointCard's own Copy button is independent of the Base URL's",
await flushPromises()
const expectedCode =
- `const res = await fetch('${STUB_ORIGIN}/v1/article/')\n` +
+ `const res = await fetch('${STUB_ORIGIN}/v1/article/?format=html')\n` +
'const articles = await res.json()'
expect(writeText).toHaveBeenCalledWith(expectedCode)
@@ -178,7 +178,7 @@ describe('curl and Response tabs render, unchecked visually', () => {
const firstCard = wrapper.findAllComponents(EndpointCard)[0]
await activateTab(firstCard.element as HTMLElement, 'curl')
- expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/`)
+ expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/?format=html`)
await activateTab(firstCard.element as HTMLElement, 'Response')