feat: articles format option with markdown new default

This commit is contained in:
fsylvain committed 2026-10-04 19:04:12 +02:00
1 parent 68401d66e0
commit 7e9bd9ba30
61 files changed
+2269 -243

No files matched your search

+6 -4
View File
@@ -10,6 +10,7 @@ import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/update"
@@ -152,13 +153,14 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
return &UseCases{
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
+102 -24
View File
@@ -12,6 +12,7 @@ import (
"log"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
@@ -41,6 +42,53 @@ func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleU
}
}
type articleResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat domain.BodyFormat `json:"body_format"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body)
if err != nil {
return articleResponse{}, err
}
body = encoded
}
return articleResponse{
ID: article.ID,
Title: article.Title,
Body: body,
BodyFormat: format,
IsOnline: article.IsOnline,
CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt,
}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
response, err := toArticleResponse(article, format)
if err != nil {
return nil, err
}
return json.Marshal(response)
}
func parseBodyFormat(w http.ResponseWriter, r *http.Request) (domain.BodyFormat, bool) {
format, err := domain.ParseBodyFormat(r.URL.Query().Get("format"))
if err != nil {
http.Error(w, apperr.ArticleBodyFormatErrorMessage, http.StatusBadRequest)
return "", false
}
return format, true
}
func parseArticleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
@@ -53,11 +101,16 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var article domain.Article
if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id)
article, err = h.GetUseCase.GetArticle(id, format)
} else {
article, err = h.GetUseCase.GetOnlineArticle(id)
article, err = h.GetUseCase.GetOnlineArticle(id, format)
}
if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
@@ -68,7 +121,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return
}
articleJson, err := json.Marshal(article)
articleJson, err := encodeArticle(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -80,6 +133,11 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle PostArticle
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -92,8 +150,9 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
}
createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{
Title: localArticle.Title,
Body: localArticle.Body,
Title: localArticle.Title,
Body: localArticle.Body,
Format: format,
})
if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) {
@@ -104,16 +163,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return
}
articleJson, err := json.Marshal(createdArticle)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
}
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
}
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
@@ -124,13 +174,16 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func writeArticleJsonResponse(w http.ResponseWriter, article domain.Article) {
articleJson, err := json.Marshal(article)
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
}
@@ -143,13 +196,18 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return
}
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline)
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline, format)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, updatedArticle)
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
}
type UpdateArticleBody struct {
@@ -163,6 +221,11 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle UpdateArticleBody
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -174,13 +237,13 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return
}
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body)
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body, format)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, updatedArticle)
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
}
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -206,19 +269,34 @@ func (h *Handler) callerIsVerified(r *http.Request) bool {
}
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var articles []domain.Article
var err error
if h.callerIsVerified(r) {
articles, err = h.ListUseCase.ListAllArticles()
articles, err = h.ListUseCase.ListAllArticles(format)
} else {
articles, err = h.ListUseCase.ListOnlineArticles()
articles, err = h.ListUseCase.ListOnlineArticles(format)
}
if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
return
}
articlesJson, err := json.Marshal(articles)
responses := make([]articleResponse, 0, len(articles))
for _, article := range articles {
response, err := toArticleResponse(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
responses = append(responses, response)
}
articlesJson, err := json.Marshal(responses)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
+1
View File
@@ -34,3 +34,4 @@ const UpdateAlreadyRunningErrorMessage = "An update is already running or waitin
const NothingToUpdateErrorMessage = "There is nothing to update."
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
+97
View File
@@ -0,0 +1,97 @@
# Reading articles
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
## Routes that honour `format`
- `GET /v1/article/`
- `GET /v1/article/{id}`
- `POST /v1/article/`
- `PATCH /v1/article/{id}`
- `PATCH /v1/article/{id}/publish`
- `PATCH /v1/article/{id}/unpublish`
Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`.
## The `format` parameter
| Value | `body` | `body_format` |
| --- | --- | --- |
| empty or absent | markdown string | `markdown` |
| `markdown` | markdown string | `markdown` |
| `html` | the stored HTML string | `html` |
| `json` | JSON array of blocks | `json` |
Values are case-insensitive. When `format` is repeated, the first value is used. Any other value answers `400` with the plain-text message `The format must be one of: markdown, html, json.` before any article is read or written. An invalid id is reported first, ahead of the format.
Breaking change: the default used to be HTML. Clients that need HTML must send `?format=html`. The web interface does so.
## Article object
```json
{
"id": 1,
"title": "Backyard Renovation Update",
"body": "## Plan\n\nReplace the **old** deck.",
"body_format": "markdown",
"is_online": true,
"created_at": "2026-03-12T09:14:00Z",
"updated_at": "2026-09-10T07:00:00Z"
}
```
## Markdown output
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
### Text alignment
Markdown has no syntax for alignment, so centered, right-aligned and justified paragraphs and headings are wrapped in a `div` with an inline style. The blank lines around the content make CommonMark parse the inside as markdown:
```markdown
<div style="text-align: center">
A **centered** paragraph
</div>
```
Left alignment is the default and produces no wrapper. Your markdown renderer must allow raw HTML for the alignment to show (`html: true` in markdown-it); otherwise the wrapper tags appear as text, and a client that does not want them can request `?format=json` instead, where the alignment is a field.
## JSON block schema
`body` is a native JSON array. Every block has a `type`.
| Block | Fields |
| --- | --- |
| `heading` | `level` (1 to 6), `align` (only when `center`, `right` or `justify`), `children` (text nodes) |
| `paragraph` | `align` (only when `center`, `right` or `justify`), `children` (text nodes, empty array for an empty paragraph) |
| `list` | `ordered` (boolean), `items` (array of items, each an array of blocks) |
| `blockquote` | `children` (blocks) |
| `code` | `language` (empty string when unknown), `text` |
| `hr` | none |
| `image` | `src`, `alt` (empty string when missing) |
Text node: `{"type":"text","text":"...","marks":[...]}`. `marks` is always an array, listed outermost first. A mark is `{"type":"bold"}`, `{"type":"italic"}` or `{"type":"link","href":"..."}`. A line break becomes a `"\n"` inside the text. Adjacent text with identical marks is merged. Inline code, underline, strike-through and other formatting degrade to plain text. An image inside a paragraph becomes its own block and splits the paragraph. An empty body is `[]`.
Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
```json
[
{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},
{"type":"paragraph","children":[
{"type":"text","text":"Replace the ","marks":[]},
{"type":"text","text":"old","marks":[{"type":"bold"}]},
{"type":"text","text":" deck.","marks":[]}
]}
]
```
## Errors
| Status | Body |
| --- | --- |
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
| `404` | `Article not found` |
| `500` | `Internal server error` when the body cannot be converted |
+1
View File
@@ -29,3 +29,4 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.5.0-beta` | v2.5.0-beta - Media library & image picker |
| `v2.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 |
+35
View File
@@ -0,0 +1,35 @@
This release lets your website read article bodies as markdown, HTML or structured blocks, and fixes text alignment in the editor.
## New
**Article body formats**
- Read an article body as markdown, HTML or a typed block array with the `format` query parameter, so a headless frontend can render it with markdown-it, comark or its own components.
- Keep text alignment in every format: markdown wraps centered, right-aligned and justified blocks in a `div` with an inline style, and the block array carries an `align` field.
- Keep plain text plain: text that merely looks like markdown, such as `# not a heading` or `- not a list`, is escaped in the markdown output.
- See the Integration page snippets and [the article API reference](../api/articles.md) for the full contract.
## Improvements and fixes
- Fix the text alignment buttons in the article editor, which were disabled and showed a blocked cursor. Paragraphs and headings can be aligned left, center, right or justified again, and unaligned text stays free of inline styles.
- The web interface requests HTML explicitly when it loads, saves, publishes, unpublishes or creates an article, so the editor and the image counts behave exactly as before.
## API
- Changed: `GET /v1/article/`, `GET /v1/article/{id}`, `POST /v1/article/`, `PATCH /v1/article/{id}`, `PATCH /v1/article/{id}/publish` and `PATCH /v1/article/{id}/unpublish` now return `body` as markdown by default. Pass `?format=html` to keep the previous HTML body.
- Every article object now includes `body_format` (`markdown`, `html` or `json`), which names the format of `body`.
- `?format=json` on the same routes returns `body` as a JSON array of typed blocks instead of a string.
- An unsupported `format` answers `400` before anything is read or written.
- Storage and the request bodies of `POST` and `PATCH` stay HTML.
## Under the hood
- Add the article body renderer port in the domain, with a markdown and JSON block adapter, and tests for every supported element.
- Update `golang.org/x/crypto` to v0.57.0 and add `golang.org/x/net` as a direct dependency for HTML parsing.
- Add `@tiptap/extension-text-align` to the web interface.
## Upgrading
- Replace the binary. No database migration or new environment variable is required.
- Clients that read an article `body` as HTML must add `?format=html` to their article requests, because the default is now markdown.
- A markdown renderer needs raw HTML enabled for text alignment to show, for example `html: true` in markdown-it.
+229
View File
@@ -0,0 +1,229 @@
Feature: Article body content negotiation on read
As a headless frontend developer
I want article routes to return the body as markdown (default), html or a typed json block array
So that my client can render it without handling editor HTML
Background:
Given article bodies are stored as Tiptap HTML
And storage, the POST /v1/article body and the PATCH /v1/article/{id} body stay HTML and are unchanged
And internal/domain/article defines BodyFormat with the values "markdown", "html" and "json"
And internal/domain/article defines the port BodyRenderer and the sentinel ErrUnsupportedBodyFormat
And ParseBodyFormat matches case-insensitively, treats an empty value as "markdown", and returns ErrUnsupportedBodyFormat otherwise
And the use cases receive the BodyRenderer through their constructors, the handler only reads the "format" query value, and only api/dependecyInjection.go builds the converter
And every article object in a response has the snake_case fields id, title, body, body_format, is_online, created_at, updated_at
And body_format is "markdown", "html" or "json" and always names the format of body
And for markdown and html, body is a JSON string; for json, body is a JSON array of blocks (not an escaped string)
And the covered routes are GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish and PATCH /v1/article/{id}/unpublish
And visibility is unchanged: anonymous and unverified callers see only online articles, and an offline article reads as 404 "Article not found"
And an unsupported format answers 400 with apperr.ArticleBodyFormatErrorMessage as plain text, before any article lookup or write
And an invalid id answers apperr.IdUint32ErrorMessage first, ahead of the format check
And a repeated "format" parameter uses its first value
And a renderer failure answers 500 "Internal server error"
And article 1 is online, titled "Backyard Renovation Update", with body "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
Scenario Outline: Single read negotiates the body format
When an anonymous client sends GET /v1/article/1<query>
Then the response status is 200
And "body" is <body>
And "body_format" is "<body_format>"
Examples:
| query | body | body_format |
| | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format= | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=markdown | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=MarkDown | "## Plan\n\nReplace the **old** deck." | markdown |
| ?format=html | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
| ?format=HTML | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
| ?format=json | [{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}] | json |
| ?format=JSON | the same array as ?format=json | json |
Scenario: List read negotiates every element
Given the repository also holds online article 3 with body "<p>Second</p>"
When an anonymous client sends GET /v1/article/
Then every element has body_format "markdown" and article 3 has body "Second"
When an anonymous client sends GET /v1/article/?format=html
Then every element has body_format "html" and its stored HTML as body
When an anonymous client sends GET /v1/article/?format=json
Then every element has body_format "json" and article 3 has body [{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]
Scenario Outline: An unsupported format is rejected
When an anonymous client sends GET <path>
Then the response status is 400
And the response body is apperr.ArticleBodyFormatErrorMessage followed by a newline
And no use case reads or renders an article
Examples:
| path |
| /v1/article/1?format=pdf |
| /v1/article/?format=xml |
| /v1/article/9999?format=pdf |
| /v1/article/1?format=md |
Scenario: An invalid id is reported before an invalid format
When an anonymous client sends GET /v1/article/abc?format=pdf
Then the response status is 400
And the response body is apperr.IdUint32ErrorMessage followed by a newline
Scenario: The first repeated format parameter wins
When an anonymous client sends GET /v1/article/1?format=html&format=json
Then "body_format" is "html"
Scenario: Offline articles stay hidden whatever the format
Given article 2 is offline
When an anonymous client sends GET /v1/article/2?format=json
Then the response status is 404 and the body is "Article not found"
Scenario Outline: Authenticated routes negotiate the response body
Given a valid jwt cookie for a verified user with id 1
When the client sends <method> <path> with <request_body>
Then the response status is <status>
And "body_format" is "<body_format>"
And the stored body is the HTML that was sent, never markdown or json
Examples:
| method | path | request_body | status | body_format |
| POST | /v1/article/ | {"title":"Deck","body":"<p>A</p>"} | 201 | markdown |
| POST | /v1/article/?format=html | {"title":"Deck","body":"<p>A</p>"} | 201 | html |
| POST | /v1/article/?format=json | {"title":"Deck","body":"<p>A</p>"} | 201 | json |
| PATCH | /v1/article/1 | {"body":"<p>B</p>"} | 200 | markdown |
| PATCH | /v1/article/1?format=json | {"body":"<p>B</p>"} | 200 | json |
| PATCH | /v1/article/1/publish | | 200 | markdown |
| PATCH | /v1/article/1/unpublish?format=html | | 200 | html |
Scenario: An unsupported format on a write route changes nothing
Given a valid jwt cookie for a verified user with id 1
When the client sends PATCH /v1/article/1?format=pdf with {"body":"<p>B</p>"}
Then the response status is 400
And the stored body of article 1 is unchanged
When the client sends POST /v1/article/?format=pdf with {"title":"Deck","body":"<p>A</p>"}
Then the response status is 400 and no article is created
Scenario: The authenticated list includes offline articles in the negotiated format
Given a valid jwt cookie for a verified user and offline article 2
When the client sends GET /v1/article/?format=json
Then the array contains articles 1 and 2 with body_format "json"
Scenario: A converter failure answers 500
Given the BodyRenderer fails on its next call
When an anonymous client sends GET /v1/article/1
Then the response status is 500 and the body is "Internal server error"
Scenario Outline: Markdown fidelity for Tiptap output
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
Examples:
| html | markdown |
| <h1>T</h1><h3>S</h3> | # T\n\n### S |
| <p><strong>b</strong> <em>i</em></p> | **b** *i* |
| <p><a href="https://example.com/x">link</a></p> | [link](https://example.com/x) |
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | - a\n- b |
| <ol><li><p>a</p></li><li><p>b</p></li></ol> | 1. a\n2. b |
| <ul><li data-indent="2"><p>a</p></li></ul> | - a |
| <blockquote><p>q</p></blockquote> | > q |
| <pre><code class="language-go">x := 1</code></pre> | ```go\nx := 1\n``` |
| <hr> | --- |
| <img src="/static/uploadedImages/a.png" alt="A"> | ![A](/static/uploadedImages/a.png) |
| <p>ok</p><script>alert(1)</script><style>p{}</style> | ok |
| <p>x<span style="color:red">y</span></p> | xy |
| <p>use <code>go test</code></p> | use `go test` |
Scenario Outline: Json block shapes, one row per block and mark type
Given the stored body is <html>
When converted with the format "json"
Then the body is <json>
Examples:
| html | json |
| <h1>T</h1> | [{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}] |
| <h6>S</h6> | [{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}] |
| <p>plain</p> | [{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}] |
| <p></p> | [{"type":"paragraph","children":[]}] |
| <p><strong>b</strong></p> | [{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}] |
| <p><em>i</em></p> | [{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}] |
| <p><a href="https://example.com/x">l</a></p> | [{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}] |
| <p><a href="/a"><strong><em>x</em></strong></a></p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}] |
| <p>a<br>b</p> | [{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}] |
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}] |
| <ol><li><p>a</p></li></ol> | [{"type":"list","ordered":true,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}]]}] |
| <ul><li>bare</li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"bare","marks":[]}]}]]}] |
| <ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul> | a list item whose blocks are the paragraph "a" followed by a nested list block with ordered false and the item "n" |
| <ul><li data-indent="2"><p>a</p></li></ul> | the same output as without data-indent |
| <blockquote><p>q</p></blockquote> | [{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}] |
| <pre><code class="language-go">x := 1</code></pre> | [{"type":"code","language":"go","text":"x := 1"}] |
| <pre><code>raw</code></pre> | [{"type":"code","language":"","text":"raw"}] |
| <hr> | [{"type":"hr"}] |
| <img src="/static/uploadedImages/a.png" alt="A"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}] |
| <img src="/static/uploadedImages/a.png"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":""}] |
| <p>x<img src="/i.png" alt="I">y</p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}] |
| <p>x<span style="color:red">y</span></p> | [{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}] |
| <p>use <code>go test</code></p> | [{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}] |
| <p>ok</p><script>alert(1)</script><style>p{}</style> | [{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}] |
Scenario Outline: Text alignment survives the conversion
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
When converted with the format "json"
Then the body is <json>
Examples:
| html | markdown | json |
| <p style="text-align: center">a</p> | <div style="text-align: center">
a
</div> | [{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}] |
| <h2 style="text-align:right">T</h2> | <div style="text-align: right">
## T
</div> | [{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}] |
| <p style="text-align: JUSTIFY">a</p> | <div style="text-align: justify">
a
</div> | [{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}] |
| <p style="text-align: left">a</p> | a | [{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}] |
| <p style="text-align: start">a</p> | a | the same output as the left row |
| <p style="text-align: center"></p> | an empty string | [{"type":"paragraph","align":"center","children":[]}] |
Scenario Outline: Plain text that looks like markdown stays plain text
Given the stored body is <html>
When converted with the format "markdown"
Then the body is <markdown>
Examples:
| html | markdown |
| <p># not a heading</p> | \# not a heading |
| <p>#tag</p> | #tag |
| <p>- not a list</p> | \- not a list |
| <p>+ not a list</p> | \+ not a list |
| <p>&gt; not a quote</p> | \> not a quote |
| <p>1. not a list</p> | 1\. not a list |
| <p>2) not a list</p> | 2\) not a list |
| <p>---</p> | \--- |
| <p>a - b</p> | a - b |
| <p>a * b _c_</p> | a \* b \_c\_ |
| <p>a<br>- b</p> | a\n\- b |
Scenario: The editor lets the author align text
Given the article editor registers the TextAlign extension for paragraphs and headings
Then the left, center, right and justify toolbar buttons are enabled
And clicking center writes style="text-align: center;" on the current paragraph or heading
And paragraphs and headings that were never aligned carry no style attribute
Scenario: The SPA asks for html explicitly
Given no visual change is expected, so docs/design is not consulted
When EditorView loads an article, or ArticleView or HomeView load the list
Then each request is /v1/article/{id}?format=html or /v1/article/?format=html
And the draft save PATCH, the publish and unpublish PATCH and the create POST also carry ?format=html, so a converter failure cannot fail a save
And the editor and the image counts receive HTML exactly as before
Scenario: The documentation reflects the contract
Then docs/api has an article read page covering format, the markdown default, body_format, the json block schema, the 400 message and the breaking default change
And web/src/views/IntegrationView.vue snippets show ?format
And docs/releases has a note stating the default flip and that the SPA sends format=html
+46
View File
@@ -0,0 +1,46 @@
# QA procedure: article_body_format
Package: `test` (black-box). New files: `test/article_body_format_test.go`, `test/markdown_converter_test.go`, `test/json_blocks_converter_test.go`. No live server, no real SQLite file, no real SMTP.
## Setup
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` and `mockUserRepo`. Add a fake `fakeBodyRenderer` with a unique type name. It records each call's format and the html it received. For markdown it returns "MD:"+html. For html it returns the html unchanged. For json it returns `[{"type":"paragraph","children":[{"type":"text","text":"FAKE","marks":[]}]}]`. It has a failure switch.
3. Wire the real use cases over the fakes, passing the renderer. Update every existing test that builds these use cases for the new constructor signatures. Build `article.NewHandler(...)`.
4. Mount a fresh `chi.NewRouter()` mirroring `api/router.go` for `/v1/article`: `jwtauth.Verifier` on the group, GET `/` and `/{id}` public, and `Authenticator` + `RequireCurrentToken` + `RequireVerifiedUser` for POST, DELETE and PATCH.
5. Mint a jwt cookie for user_id 1 with `token.Encode(map[string]any{"user_id": uint32(1)})`. Seed a verified user. Seed articles 1 (online) and 2 (offline).
## HTTP checks (fake renderer)
6. Single read matrix: for each of none, `?format=`, markdown, MarkDown, html, HTML, json, JSON, assert status 200, `body_format`, the renderer's recorded format and the stored HTML passed in. For json, decode `body` and assert it is a JSON array (not a string).
7. List with none, `?format=html` and `?format=json`: every element has the matching `body_format`.
8. `format=pdf`, `xml` and `md` on single, list and unknown-id paths return 400 with `apperr.ArticleBodyFormatErrorMessage + "\n"`. The renderer and the repo are not called.
9. `GET /v1/article/abc?format=pdf` returns 400 with `apperr.IdUint32ErrorMessage + "\n"`.
10. `?format=html&format=json` returns html.
11. `GET /v1/article/2?format=json` anonymous returns 404 `"Article not found\n"`.
12. Write-route matrix with a cookie (POST, PATCH, publish, unpublish, each with none, html and json where listed): assert status, `body_format`, and that the repo holds the HTML that was sent.
13. `PATCH /v1/article/1?format=pdf` and `POST /v1/article/?format=pdf` with a cookie return 400. The repo is unchanged and no article is created.
14. Authenticated `GET /v1/article/?format=json` includes articles 1 and 2.
15. Renderer failure switch, then `GET /v1/article/1` returns 500 `"Internal server error\n"`.
## Domain
16. `ParseBodyFormat`: "", markdown, MARKDOWN, html, Html, json, JSON are accepted (empty returns markdown). pdf, md and " html" return `errors.Is(err, ErrUnsupportedBodyFormat)`.
## Converter tests (real library, real Tiptap HTML samples)
17. `test/markdown_converter_test.go`: every row of the markdown fidelity table, plus a combined document (heading, paragraph with marks, list, image, code block, quote, hr). Assert exact output. For format html, assert the input comes back byte-identical.
17b. Alignment and escaping: every row of the alignment and plain-text tables, for markdown and json, in `test/markdown_converter_test.go` and `test/json_blocks_converter_test.go`. `test/body_converter_documents_test.go` covers the combined hostile document (no raw script, style, iframe or span leaks in markdown or json), its block types, and code block text extraction.
18. `test/json_blocks_converter_test.go`: every row of the json shapes table. Assert by unmarshalling both sides and comparing with deep equality. This also asserts `marks` is `[]` rather than null or absent, `ordered` is a bool, and code `language` is "" when absent. Also include a combined document with nested list, blockquote and an image inside a paragraph. Assert that script and style content never appears, and that no raw HTML survives in any output for any format except html.
19. `test/json_blocks_converter_test.go` also asserts that html "" produces `[]`, and that the output is a non-null JSON array.
## SPA (Vitest, `task test:web`)
20. Mock `/v1/article/12?format=html` and assert `EditorView` requests that URL and loads the HTML body into the draft.
21. Assert `useArticles.listArticles` requests `/v1/article/?format=html`.
22. Assert the `ArticleView` image count is computed from the HTML.
23. Assert `useArticleDraft.save` sends PATCH `/v1/article/{id}?format=html` with the JSON body.
24. Reported as NOT RUN if there is no runner. No visual check applies, because no visual change is expected.
## Docs
25. Check that a docs/api article read page, the IntegrationView snippets and a docs/releases note exist as described.
## Manual verification
- In the embedded SPA, edit and save an article against a real SQLite file and confirm the stored body is still HTML.
- `curl` the public list and a single article with no format, `?format=html` and `?format=json` against a built binary.
- No SMTP involvement.
+2 -1
View File
@@ -11,8 +11,9 @@ require (
github.com/go-playground/validator/v10 v10.28.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.44.0
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/gorm v1.31.1
)
+4 -2
View File
@@ -78,14 +78,16 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+32
View File
@@ -0,0 +1,32 @@
package article
import (
"errors"
"strings"
)
var ErrUnsupportedBodyFormat = errors.New("unsupported body format")
type BodyFormat string
const (
BodyFormatMarkdown BodyFormat = "markdown"
BodyFormatHTML BodyFormat = "html"
BodyFormatJSON BodyFormat = "json"
)
func ParseBodyFormat(value string) (BodyFormat, error) {
switch strings.ToLower(value) {
case "", string(BodyFormatMarkdown):
return BodyFormatMarkdown, nil
case string(BodyFormatHTML):
return BodyFormatHTML, nil
case string(BodyFormatJSON):
return BodyFormatJSON, nil
}
return "", ErrUnsupportedBodyFormat
}
type BodyRenderer interface {
Render(html string, format BodyFormat) (string, error)
}
+365
View File
@@ -0,0 +1,365 @@
package markdown
import (
"net/url"
"strconv"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
const (
markBold = "bold"
markItalic = "italic"
markLink = "link"
markCode = "code"
codeLanguagePrefix = "language-"
)
type mark struct {
kind string
href string
}
type inline struct {
text string
marks []mark
}
type blockKind int
const (
kindHeading blockKind = iota
kindParagraph
kindList
kindBlockquote
kindCode
kindRule
kindImage
)
type block struct {
kind blockKind
level int
inlines []inline
ordered bool
items [][]block
children []block
language string
text string
src string
alt string
align string
}
var transparentBlocks = map[string]bool{
"div": true, "section": true, "article": true, "header": true, "footer": true,
"main": true, "aside": true, "nav": true, "figure": true, "figcaption": true,
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true,
"td": true, "th": true, "dl": true, "dt": true, "dd": true, "details": true,
"summary": true, "li": true,
}
var skippedElements = map[string]bool{
"script": true, "style": true, "template": true, "head": true,
"noscript": true, "iframe": true, "object": true, "embed": true,
}
var alignments = map[string]bool{"center": true, "right": true, "justify": true}
var allowedLinkSchemes = map[string]bool{
"": true, "http": true, "https": true, "mailto": true, "tel": true,
}
func parseBlocks(source string) ([]block, error) {
context := &html.Node{Type: html.ElementNode, DataAtom: atom.Body, Data: "body"}
nodes, err := html.ParseFragment(strings.NewReader(source), context)
if err != nil {
return nil, err
}
collector := newCollector(false, paragraphBlock)
collector.walkAll(nodes, nil)
return collector.finish(false), nil
}
func paragraphBlock(inlines []inline) block {
return block{kind: kindParagraph, inlines: inlines}
}
func headingBlock(level int) func([]inline) block {
return func(inlines []inline) block {
return block{kind: kindHeading, level: level, inlines: inlines}
}
}
type collector struct {
out []block
current []inline
keepBlank bool
build func([]inline) block
}
func newCollector(keepBlank bool, build func([]inline) block) *collector {
return &collector{keepBlank: keepBlank, build: build}
}
func (c *collector) walkAll(nodes []*html.Node, marks []mark) {
for _, node := range nodes {
c.walk(node, marks)
}
}
func (c *collector) walkChildren(parent *html.Node, marks []mark) {
for child := parent.FirstChild; child != nil; child = child.NextSibling {
c.walk(child, marks)
}
}
func (c *collector) addText(text string, marks []mark) {
if text == "" {
return
}
c.current = appendInline(c.current, inline{text: text, marks: marks})
}
func (c *collector) flush() {
if len(c.current) == 0 {
return
}
if c.keepBlank || hasVisibleText(c.current) {
c.out = append(c.out, c.build(c.current))
}
c.current = nil
}
func (c *collector) finish(emitEmpty bool) []block {
hadBlocks := len(c.out) > 0
c.flush()
if emitEmpty && !hadBlocks && len(c.out) == 0 {
c.out = append(c.out, c.build(nil))
}
return c.out
}
func (c *collector) walk(node *html.Node, marks []mark) {
switch node.Type {
case html.TextNode:
c.addText(node.Data, marks)
return
case html.ElementNode:
default:
return
}
tag := node.Data
if skippedElements[tag] {
return
}
switch tag {
case "br":
c.addText("\n", marks)
case "strong", "b":
c.walkChildren(node, withMark(marks, mark{kind: markBold}))
case "em", "i":
c.walkChildren(node, withMark(marks, mark{kind: markItalic}))
case "code":
c.walkChildren(node, withMark(marks, mark{kind: markCode}))
case "a":
href := safeHref(attr(node, "href"))
if href == "" {
c.walkChildren(node, marks)
return
}
c.walkChildren(node, withMark(marks, mark{kind: markLink, href: href}))
case "img":
c.flush()
if src := attr(node, "src"); src != "" {
c.out = append(c.out, block{kind: kindImage, src: src, alt: attr(node, "alt")})
}
case "hr":
c.flush()
c.out = append(c.out, block{kind: kindRule})
case "p":
c.flush()
c.out = append(c.out, parseTextBlock(node, paragraphBlock)...)
case "h1", "h2", "h3", "h4", "h5", "h6":
c.flush()
level, _ := strconv.Atoi(tag[1:])
c.out = append(c.out, parseTextBlock(node, headingBlock(level))...)
case "ul", "ol":
c.flush()
c.out = append(c.out, parseList(node))
case "blockquote":
c.flush()
c.out = append(c.out, block{kind: kindBlockquote, children: parseContainer(node)})
case "pre":
c.flush()
c.out = append(c.out, parseCodeBlock(node))
default:
if transparentBlocks[tag] {
c.flush()
c.walkChildren(node, marks)
c.flush()
return
}
c.walkChildren(node, marks)
}
}
func parseTextBlock(node *html.Node, build func([]inline) block) []block {
inner := newCollector(true, build)
inner.walkChildren(node, nil)
blocks := inner.finish(true)
if align := alignFromStyle(attr(node, "style")); align != "" {
for index := range blocks {
if blocks[index].kind == kindParagraph || blocks[index].kind == kindHeading {
blocks[index].align = align
}
}
}
return blocks
}
func alignFromStyle(style string) string {
for _, declaration := range strings.Split(style, ";") {
name, value, found := strings.Cut(declaration, ":")
if !found || strings.ToLower(strings.TrimSpace(name)) != "text-align" {
continue
}
if align := strings.ToLower(strings.TrimSpace(value)); alignments[align] {
return align
}
}
return ""
}
func parseContainer(node *html.Node) []block {
inner := newCollector(false, paragraphBlock)
inner.walkChildren(node, nil)
return inner.finish(false)
}
func parseList(node *html.Node) block {
list := block{kind: kindList, ordered: node.Data == "ol"}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == "li" {
list.items = append(list.items, parseContainer(child))
}
}
return list
}
func parseCodeBlock(node *html.Node) block {
code := block{kind: kindCode, text: strings.TrimSuffix(textContent(node), "\n")}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == "code" {
code.language = languageFromClass(attr(child, "class"))
break
}
}
return code
}
func languageFromClass(class string) string {
for _, name := range strings.Fields(class) {
if strings.HasPrefix(name, codeLanguagePrefix) {
return strings.TrimPrefix(name, codeLanguagePrefix)
}
}
return ""
}
func textContent(node *html.Node) string {
var builder strings.Builder
var visit func(*html.Node)
visit = func(n *html.Node) {
switch {
case n.Type == html.TextNode:
builder.WriteString(n.Data)
case n.Type == html.ElementNode && skippedElements[n.Data]:
return
case n.Type == html.ElementNode && n.Data == "br":
builder.WriteString("\n")
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
visit(child)
}
}
visit(node)
return builder.String()
}
func attr(node *html.Node, name string) string {
for _, a := range node.Attr {
if a.Key == name {
return a.Val
}
}
return ""
}
func safeHref(href string) string {
trimmed := strings.TrimSpace(href)
parsed, err := url.Parse(trimmed)
if err != nil || !allowedLinkSchemes[strings.ToLower(parsed.Scheme)] {
return ""
}
return trimmed
}
func withMark(marks []mark, added mark) []mark {
for _, existing := range marks {
if existing.kind == added.kind {
return marks
}
}
next := make([]mark, 0, len(marks)+1)
next = append(next, marks...)
return append(next, added)
}
func hasVisibleText(inlines []inline) bool {
for _, item := range inlines {
if strings.TrimSpace(item.text) != "" {
return true
}
}
return false
}
func sameMarks(a, b []mark) bool {
if len(a) != len(b) {
return false
}
for index := range a {
if a[index] != b[index] {
return false
}
}
return true
}
func appendInline(inlines []inline, next inline) []inline {
if last := len(inlines) - 1; last >= 0 && sameMarks(inlines[last].marks, next.marks) {
inlines[last].text += next.text
return inlines
}
return append(inlines, next)
}
func withoutCodeMarks(inlines []inline) []inline {
merged := make([]inline, 0, len(inlines))
for _, item := range inlines {
kept := make([]mark, 0, len(item.marks))
for _, m := range item.marks {
if m.kind != markCode {
kept = append(kept, m)
}
}
merged = appendInline(merged, inline{text: item.text, marks: kept})
}
return merged
}
@@ -0,0 +1,33 @@
package markdown
import (
domain "RenewCMS/internal/domain/article"
)
type Converter struct{}
var _ domain.BodyRenderer = &Converter{}
func NewConverter() *Converter {
return &Converter{}
}
func (c *Converter) Render(html string, format domain.BodyFormat) (string, error) {
switch format {
case domain.BodyFormatHTML:
return html, nil
case domain.BodyFormatMarkdown:
blocks, err := parseBlocks(html)
if err != nil {
return "", err
}
return renderMarkdown(blocks), nil
case domain.BodyFormatJSON:
blocks, err := parseBlocks(html)
if err != nil {
return "", err
}
return renderJSON(blocks)
}
return "", domain.ErrUnsupportedBodyFormat
}
@@ -0,0 +1,111 @@
package markdown
import (
"bytes"
"encoding/json"
"strings"
)
type jsonMark struct {
Type string `json:"type"`
Href string `json:"href,omitempty"`
}
type jsonText struct {
Type string `json:"type"`
Text string `json:"text"`
Marks []jsonMark `json:"marks"`
}
type jsonHeading struct {
Type string `json:"type"`
Level int `json:"level"`
Align string `json:"align,omitempty"`
Children []jsonText `json:"children"`
}
type jsonParagraph struct {
Type string `json:"type"`
Align string `json:"align,omitempty"`
Children []jsonText `json:"children"`
}
type jsonList struct {
Type string `json:"type"`
Ordered bool `json:"ordered"`
Items [][]any `json:"items"`
}
type jsonBlockquote struct {
Type string `json:"type"`
Children []any `json:"children"`
}
type jsonCode struct {
Type string `json:"type"`
Language string `json:"language"`
Text string `json:"text"`
}
type jsonRule struct {
Type string `json:"type"`
}
type jsonImage struct {
Type string `json:"type"`
Src string `json:"src"`
Alt string `json:"alt"`
}
func renderJSON(blocks []block) (string, error) {
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(toJSONBlocks(blocks)); err != nil {
return "", err
}
return strings.TrimSuffix(buffer.String(), "\n"), nil
}
func toJSONBlocks(blocks []block) []any {
result := make([]any, 0, len(blocks))
for _, b := range blocks {
result = append(result, toJSONBlock(b))
}
return result
}
func toJSONBlock(b block) any {
switch b.kind {
case kindHeading:
return jsonHeading{Type: "heading", Level: b.level, Align: b.align, Children: toJSONTexts(b.inlines)}
case kindParagraph:
return jsonParagraph{Type: "paragraph", Align: b.align, Children: toJSONTexts(b.inlines)}
case kindList:
items := make([][]any, 0, len(b.items))
for _, item := range b.items {
items = append(items, toJSONBlocks(item))
}
return jsonList{Type: "list", Ordered: b.ordered, Items: items}
case kindBlockquote:
return jsonBlockquote{Type: "blockquote", Children: toJSONBlocks(b.children)}
case kindCode:
return jsonCode{Type: "code", Language: b.language, Text: b.text}
case kindImage:
return jsonImage{Type: "image", Src: b.src, Alt: b.alt}
}
return jsonRule{Type: "hr"}
}
func toJSONTexts(inlines []inline) []jsonText {
plain := withoutCodeMarks(inlines)
texts := make([]jsonText, 0, len(plain))
for _, item := range plain {
marks := make([]jsonMark, 0, len(item.marks))
for _, m := range item.marks {
marks = append(marks, jsonMark{Type: m.kind, Href: m.href})
}
texts = append(texts, jsonText{Type: "text", Text: item.text, Marks: marks})
}
return texts
}
@@ -0,0 +1,213 @@
package markdown
import (
"regexp"
"strconv"
"strings"
)
const (
blockSeparator = "\n\n"
hardBreak = "\\\n"
bulletMarker = "- "
codeFence = "```"
alignOpen = `<div style="text-align: `
alignOpenEnd = `">`
alignClose = "</div>"
)
var textEscaper = strings.NewReplacer(
"\\", "\\\\", "*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]", "<", "\\<",
)
var (
numberedLineStart = regexp.MustCompile(`^(\d+)([.)])(\s|$)`)
markerLineStart = regexp.MustCompile(`^(>|#{1,6}(\s|$)|[+-](\s|$|-)|=+\s*$)`)
)
var hrefEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
func renderMarkdown(blocks []block) string {
return renderBlocks(blocks, blockSeparator)
}
func renderBlocks(blocks []block, separator string) string {
parts := make([]string, 0, len(blocks))
for _, b := range blocks {
if rendered := renderBlock(b); rendered != "" {
parts = append(parts, rendered)
}
}
return strings.Join(parts, separator)
}
func renderBlock(b block) string {
rendered := renderPlainBlock(b)
if b.align == "" || rendered == "" {
return rendered
}
return alignOpen + b.align + alignOpenEnd + blockSeparator + rendered + blockSeparator + alignClose
}
func renderPlainBlock(b block) string {
switch b.kind {
case kindHeading:
return strings.Repeat("#", b.level) + " " + renderInlines(b.inlines)
case kindParagraph:
return escapeLineStarts(renderInlines(b.inlines))
case kindList:
return renderList(b)
case kindBlockquote:
return prefixLines(renderBlocks(b.children, blockSeparator), "> ", ">")
case kindCode:
return renderCode(b)
case kindImage:
return "![" + escapeAlt(b.alt) + "](" + hrefEscaper.Replace(b.src) + ")"
}
return "---"
}
func escapeLineStarts(text string) string {
lines := strings.Split(text, "\n")
for index, line := range lines {
line = numberedLineStart.ReplaceAllString(line, `$1\$2$3`)
if markerLineStart.MatchString(line) {
line = `\` + line
}
lines[index] = line
}
return strings.Join(lines, "\n")
}
func renderCode(b block) string {
fence := codeFence
for strings.Contains(b.text, fence) {
fence += "`"
}
return fence + b.language + "\n" + b.text + "\n" + fence
}
func escapeAlt(alt string) string {
return strings.NewReplacer("[", "\\[", "]", "\\]", "\n", " ").Replace(alt)
}
func renderList(b block) string {
lines := make([]string, 0, len(b.items))
for index, item := range b.items {
marker := bulletMarker
if b.ordered {
marker = strconv.Itoa(index+1) + ". "
}
content := renderBlocks(item, itemSeparator(item))
lines = append(lines, marker+indentContinuation(content, len(marker)))
}
return strings.Join(lines, "\n")
}
func itemSeparator(item []block) string {
textBlocks := 0
for _, b := range item {
if b.kind != kindList {
textBlocks++
}
}
if textBlocks > 1 {
return blockSeparator
}
return "\n"
}
func indentContinuation(content string, width int) string {
indent := strings.Repeat(" ", width)
lines := strings.Split(content, "\n")
for index := 1; index < len(lines); index++ {
if lines[index] != "" {
lines[index] = indent + lines[index]
}
}
return strings.Join(lines, "\n")
}
func prefixLines(content, prefix, emptyPrefix string) string {
lines := strings.Split(content, "\n")
for index, line := range lines {
if line == "" {
lines[index] = emptyPrefix
continue
}
lines[index] = prefix + line
}
return strings.Join(lines, "\n")
}
func renderInlines(inlines []inline) string {
var builder strings.Builder
for _, item := range inlines {
builder.WriteString(renderInline(item))
}
return builder.String()
}
func renderInline(item inline) string {
isCode := hasMarkKind(item.marks, markCode)
lines := strings.Split(item.text, "\n")
for index, line := range lines {
lines[index] = wrapMarks(item.marks, line, isCode)
}
return strings.Join(lines, hardBreak)
}
func hasMarkKind(marks []mark, kind string) bool {
for _, m := range marks {
if m.kind == kind {
return true
}
}
return false
}
func wrapMarks(marks []mark, text string, isCode bool) string {
core := strings.TrimSpace(text)
if core == "" {
return text
}
start := strings.Index(text, core)
leading, trailing := text[:start], text[start+len(core):]
var wrapped string
if isCode {
wrapped = inlineCode(core)
} else {
wrapped = escapeText(core)
}
for index := len(marks) - 1; index >= 0; index-- {
wrapped = applyMark(marks[index], wrapped)
}
return leading + wrapped + trailing
}
func inlineCode(text string) string {
fence := "`"
for strings.Contains(text, fence) {
fence += "`"
}
if strings.HasPrefix(text, "`") || strings.HasSuffix(text, "`") {
return fence + " " + text + " " + fence
}
return fence + text + fence
}
func applyMark(m mark, text string) string {
switch m.kind {
case markBold:
return "**" + text + "**"
case markItalic:
return "*" + text + "*"
case markLink:
return "[" + text + "](" + hrefEscaper.Replace(m.href) + ")"
}
return text
}
func escapeText(text string) string {
return textEscaper.Replace(text)
}
@@ -6,15 +6,17 @@ import (
type CreateArticleUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
}
type CreateArticleCommand struct {
Title string
Body string
Title string
Body string
Format article.BodyFormat
}
func NewCreateArticleUseCase(articleRepository article.Repository) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository}
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository, bodyRenderer}
}
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
@@ -22,9 +24,13 @@ func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand)
return article.Article{}, err
}
return g.articleRepository.Create(article.Article{
created, err := g.articleRepository.Create(article.Article{
Title: createArticle.Title,
Body: createArticle.Body,
IsOnline: false,
})
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, created, createArticle.Format)
}
+11 -6
View File
@@ -4,13 +4,14 @@ import "RenewCMS/internal/domain/article"
type GetArticleUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
}
func NewGetArticleUseCase(articleRepository article.Repository) *GetArticleUseCase {
return &GetArticleUseCase{articleRepository}
func NewGetArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *GetArticleUseCase {
return &GetArticleUseCase{articleRepository, bodyRenderer}
}
func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error) {
func (u *GetArticleUseCase) GetOnlineArticle(id uint32, format article.BodyFormat) (article.Article, error) {
art, err := u.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
@@ -20,11 +21,15 @@ func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error)
return article.Article{}, article.ErrArticleNotFound
}
return art, nil
return renderArticle(u.bodyRenderer, art, format)
}
func (u *GetArticleUseCase) GetArticle(id uint32) (article.Article, error) {
return u.articleRepository.Get(id)
func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat) (article.Article, error) {
art, err := u.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
}
return renderArticle(u.bodyRenderer, art, format)
}
func (g *GetArticleUseCase) GetArticleByName(name string) (article.Article, error) {
@@ -4,19 +4,28 @@ import "RenewCMS/internal/domain/article"
type ListArticlesUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
}
func NewListArticlesUseCase(articleRepository article.Repository) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository}
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository, bodyRenderer}
}
func (u *ListArticlesUseCase) ListOnlineArticles() ([]article.Article, error) {
func (u *ListArticlesUseCase) ListOnlineArticles(format article.BodyFormat) ([]article.Article, error) {
online := true
return u.articleRepository.FindByFilters(article.Filters{
articles, err := u.articleRepository.FindByFilters(article.Filters{
IsOnline: &online,
})
if err != nil {
return nil, err
}
return renderArticles(u.bodyRenderer, articles, format)
}
func (u *ListArticlesUseCase) ListAllArticles() ([]article.Article, error) {
return u.articleRepository.FindByFilters(article.Filters{})
func (u *ListArticlesUseCase) ListAllArticles(format article.BodyFormat) ([]article.Article, error) {
articles, err := u.articleRepository.FindByFilters(article.Filters{})
if err != nil {
return nil, err
}
return renderArticles(u.bodyRenderer, articles, format)
}
@@ -4,16 +4,25 @@ import "RenewCMS/internal/domain/article"
type UpdateArticleUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
}
func NewUpdateArticleUseCase(articleRepository article.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository, bodyRenderer}
}
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Article, error) {
return g.articleRepository.UpdateBody(id, body)
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string, format article.BodyFormat) (article.Article, error) {
updated, err := g.articleRepository.UpdateBody(id, body)
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, updated, format)
}
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
return g.articleRepository.UpdateIsOnline(id, isOnline)
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
if err != nil {
return article.Article{}, err
}
return renderArticle(g.bodyRenderer, updated, format)
}
@@ -0,0 +1,24 @@
package useCases
import "RenewCMS/internal/domain/article"
func renderArticle(renderer article.BodyRenderer, art article.Article, format article.BodyFormat) (article.Article, error) {
body, err := renderer.Render(art.Body, format)
if err != nil {
return article.Article{}, err
}
art.Body = body
return art, nil
}
func renderArticles(renderer article.BodyRenderer, articles []article.Article, format article.BodyFormat) ([]article.Article, error) {
rendered := make([]article.Article, 0, len(articles))
for _, art := range articles {
renderedArticle, err := renderArticle(renderer, art, format)
if err != nil {
return nil, err
}
rendered = append(rendered, renderedArticle)
}
return rendered, nil
}
+361
View File
@@ -0,0 +1,361 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
type fakeBodyRenderer struct {
fail bool
calls int
}
var _ domainArticle.BodyRenderer = &fakeBodyRenderer{}
func (f *fakeBodyRenderer) Render(html string, format domainArticle.BodyFormat) (string, error) {
f.calls++
if f.fail {
return "", errors.New("render failed")
}
return html, nil
}
const (
bodyFormatArticleOneHTML = "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
bodyFormatArticleOneMD = "## Plan\n\nReplace the **old** deck."
bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]`
)
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, renderer),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
})
})
})
return r
}
func seedBodyFormatRepo() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Body: bodyFormatArticleOneHTML, IsOnline: true}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Draft: Kitchen Remodel", Body: "<p>Draft</p>", IsOnline: false}
repo.articles[3] = domainArticle.Article{ID: 3, Title: "Second article", Body: "<p>Second</p>", IsOnline: true}
repo.nextID = 3
return repo
}
type bodyFormatResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat string `json:"body_format"`
IsOnline bool `json:"is_online"`
}
func serveBodyFormat(router http.Handler, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func decodeBodyFormatArticle(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) bodyFormatResponse {
t.Helper()
if rec.Code != expectedStatus {
t.Fatalf("expected status %d, got %d, body %q", expectedStatus, rec.Code, rec.Body.String())
}
var got bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
return got
}
func assertBodyFormat(t *testing.T, got bodyFormatResponse, format, expectedBody string) {
t.Helper()
if got.BodyFormat != format {
t.Errorf("expected body_format %q, got %q", format, got.BodyFormat)
}
if format == "json" {
assertSameJSON(t, string(got.Body), expectedBody)
return
}
var text string
if err := json.Unmarshal(got.Body, &text); err != nil {
t.Fatalf("body is not a JSON string: %s", got.Body)
}
if text != expectedBody {
t.Errorf("expected body %q, got %q", expectedBody, text)
}
}
func assertSameJSON(t *testing.T, actual, expected string) {
t.Helper()
var actualValue, expectedValue any
if err := json.Unmarshal([]byte(actual), &actualValue); err != nil {
t.Fatalf("actual is not JSON: %s", actual)
}
if err := json.Unmarshal([]byte(expected), &expectedValue); err != nil {
t.Fatalf("expected is not JSON: %s", expected)
}
actualBytes, _ := json.Marshal(actualValue)
expectedBytes, _ := json.Marshal(expectedValue)
if string(actualBytes) != string(expectedBytes) {
t.Errorf("expected %s, got %s", expectedBytes, actualBytes)
}
}
func TestArticleBodyFormatHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux {
return newBodyFormatRouter(repo, users, renderer, token)
}
realRouter := func() (*chi.Mux, *mockArticleRepo) {
repo := seedBodyFormatRepo()
return newRouter(repo, newPublicArticleReadUserRepo(true), markdown.NewConverter()), repo
}
verifiedCookie := mintPublicArticleReadCookie(t, token, 1)
t.Run("Single read negotiates the body format", func(t *testing.T) {
examples := []struct {
query, format, body string
}{
{"", "markdown", bodyFormatArticleOneMD},
{"?format=", "markdown", bodyFormatArticleOneMD},
{"?format=markdown", "markdown", bodyFormatArticleOneMD},
{"?format=MarkDown", "markdown", bodyFormatArticleOneMD},
{"?format=html", "html", bodyFormatArticleOneHTML},
{"?format=HTML", "html", bodyFormatArticleOneHTML},
{"?format=json", "json", bodyFormatArticleOneJSON},
{"?format=JSON", "json", bodyFormatArticleOneJSON},
}
for _, example := range examples {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1"+example.query, "", nil)
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
assertBodyFormat(t, got, example.format, example.body)
}
})
t.Run("List read negotiates every element", func(t *testing.T) {
router, _ := realRouter()
expectations := []struct {
query, format, secondBody string
}{
{"", "markdown", "Second"},
{"?format=html", "html", "<p>Second</p>"},
{"?format=json", "json", `[{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]`},
}
for _, expectation := range expectations {
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/"+expectation.query, "", nil)
var items []bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("failed to decode list: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 online articles, got %d", len(items))
}
for _, item := range items {
if item.BodyFormat != expectation.format {
t.Errorf("expected body_format %q, got %q", expectation.format, item.BodyFormat)
}
if item.ID == 3 {
assertBodyFormat(t, item, expectation.format, expectation.secondBody)
}
}
}
})
t.Run("An unsupported format is rejected", func(t *testing.T) {
for _, path := range []string{"/v1/article/1?format=pdf", "/v1/article/?format=xml", "/v1/article/9999?format=pdf", "/v1/article/1?format=md"} {
renderer := &fakeBodyRenderer{}
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), renderer)
rec := serveBodyFormat(router, http.MethodGet, path, "", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s: expected status 400, got %d", path, rec.Code)
}
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
t.Errorf("%s: unexpected body %q", path, rec.Body.String())
}
if renderer.calls != 0 {
t.Errorf("%s: renderer must not be called", path)
}
}
})
t.Run("An invalid id is reported before an invalid format", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/abc?format=pdf", "", nil)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
t.Run("The first repeated format parameter wins", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1?format=html&format=json", "", nil)
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
assertBodyFormat(t, got, "html", bodyFormatArticleOneHTML)
})
t.Run("Offline articles stay hidden whatever the format", func(t *testing.T) {
unverified := newRouter(seedBodyFormatRepo(), newMockUserRepo(), markdown.NewConverter())
rec := serveBodyFormat(unverified, http.MethodGet, "/v1/article/2?format=json", "", nil)
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
t.Run("Authenticated routes negotiate the response body", func(t *testing.T) {
paragraph := func(text string) string {
return `[{"type":"paragraph","children":[{"type":"text","text":"` + text + `","marks":[]}]}]`
}
type expectedBodies map[string]string
bodiesA := expectedBodies{"markdown": "A", "html": "<p>A</p>", "json": paragraph("A")}
bodiesB := expectedBodies{"markdown": "B", "html": "<p>B</p>", "json": paragraph("B")}
bodiesOne := expectedBodies{"markdown": bodyFormatArticleOneMD, "html": bodyFormatArticleOneHTML, "json": bodyFormatArticleOneJSON}
examples := []struct {
method, path, body string
status int
format string
bodies expectedBodies
}{
{http.MethodPost, "/v1/article/", `{"title":"Deck","body":"<p>A</p>"}`, 201, "markdown", bodiesA},
{http.MethodPost, "/v1/article/?format=html", `{"title":"Deck","body":"<p>A</p>"}`, 201, "html", bodiesA},
{http.MethodPost, "/v1/article/?format=json", `{"title":"Deck","body":"<p>A</p>"}`, 201, "json", bodiesA},
{http.MethodPatch, "/v1/article/1", `{"body":"<p>B</p>"}`, 200, "markdown", bodiesB},
{http.MethodPatch, "/v1/article/1?format=json", `{"body":"<p>B</p>"}`, 200, "json", bodiesB},
{http.MethodPatch, "/v1/article/1/publish", "", 200, "markdown", bodiesOne},
{http.MethodPatch, "/v1/article/1/unpublish?format=html", "", 200, "html", bodiesOne},
}
for _, example := range examples {
router, repo := realRouter()
rec := serveBodyFormat(router, example.method, example.path, example.body, verifiedCookie)
got := decodeBodyFormatArticle(t, rec, example.status)
assertBodyFormat(t, got, example.format, example.bodies[example.format])
stored := repo.articles[got.ID].Body
if example.bodies["html"] != stored {
t.Errorf("%s %s: stored body must stay HTML %q, got %q", example.method, example.path, example.bodies["html"], stored)
}
}
})
t.Run("An unsupported format on a write route changes nothing", func(t *testing.T) {
requests := []struct {
method, path, body string
}{
{http.MethodPatch, "/v1/article/1?format=pdf", `{"body":"<p>B</p>"}`},
{http.MethodPost, "/v1/article/?format=pdf", `{"title":"Deck","body":"<p>A</p>"}`},
{http.MethodPatch, "/v1/article/1/publish?format=pdf", ""},
{http.MethodPatch, "/v1/article/1/unpublish?format=pdf", ""},
}
for _, request := range requests {
repo := seedBodyFormatRepo()
renderer := &fakeBodyRenderer{}
router := newRouter(repo, newPublicArticleReadUserRepo(true), renderer)
rec := serveBodyFormat(router, request.method, request.path, request.body, verifiedCookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s %s: expected 400, got %d", request.method, request.path, rec.Code)
}
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
t.Errorf("%s %s: unexpected body %q", request.method, request.path, rec.Body.String())
}
if renderer.calls != 0 {
t.Errorf("%s %s: renderer must not be called", request.method, request.path)
}
if repo.articles[1].Body != bodyFormatArticleOneHTML {
t.Errorf("%s %s: stored body changed to %q", request.method, request.path, repo.articles[1].Body)
}
if !repo.articles[1].IsOnline {
t.Errorf("%s %s: is_online changed", request.method, request.path)
}
if repo.createCalled {
t.Errorf("%s %s: no article may be created", request.method, request.path)
}
}
})
t.Run("The authenticated list includes offline articles in the negotiated format", func(t *testing.T) {
router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/?format=json", "", verifiedCookie)
var items []bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("failed to decode list: %v", err)
}
seen := map[uint32]bool{}
for _, item := range items {
seen[item.ID] = true
if item.BodyFormat != "json" {
t.Errorf("expected json, got %q", item.BodyFormat)
}
}
if !seen[1] || !seen[2] {
t.Errorf("expected articles 1 and 2, got %v", seen)
}
})
t.Run("A converter failure answers 500", func(t *testing.T) {
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), &fakeBodyRenderer{fail: true})
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1", "", nil)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
}
func TestParseBodyFormat(t *testing.T) {
valid := map[string]domainArticle.BodyFormat{
"": domainArticle.BodyFormatMarkdown, "markdown": domainArticle.BodyFormatMarkdown, "MARKDOWN": domainArticle.BodyFormatMarkdown,
"html": domainArticle.BodyFormatHTML, "Html": domainArticle.BodyFormatHTML, "json": domainArticle.BodyFormatJSON,
}
for input, expected := range valid {
got, err := domainArticle.ParseBodyFormat(input)
if err != nil || got != expected {
t.Errorf("ParseBodyFormat(%q) = %q, %v", input, got, err)
}
}
for _, input := range []string{"pdf", "md", " html"} {
if _, err := domainArticle.ParseBodyFormat(input); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
t.Errorf("ParseBodyFormat(%q) should be unsupported, got %v", input, err)
}
}
}
+4 -4
View File
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+4 -4
View File
@@ -148,7 +148,7 @@ var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
@@ -174,17 +174,17 @@ func TestGetOnlineArticle(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, IsOnline: true}
repo.articles[2] = article.Article{ID: 2, IsOnline: false}
uc := useCases.NewGetArticleUseCase(repo)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Article Online", func(t *testing.T) {
_, err := uc.GetOnlineArticle(1)
_, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil {
t.Errorf("Expected article to be found")
}
})
t.Run("Article Offline", func(t *testing.T) {
_, err := uc.GetOnlineArticle(2)
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("Offline article should return not found")
}
+5 -5
View File
@@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo),
useCases.NewGetArticleUseCase(repo),
useCases.NewListArticlesUseCase(repo),
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}),
getUserUseCase,
)
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
title := strings.Repeat("\U0001F600", tc.runeCount)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
+4 -4
View File
@@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+78
View File
@@ -0,0 +1,78 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"encoding/json"
"reflect"
"strings"
"testing"
)
const hostileBody = `<h1>Title</h1><p>Intro <strong>bold</strong><script>alert(1)</script></p><style>p{}</style><iframe src="x"></iframe><ul><li><p>one</p><ul><li><p>deep</p></li></ul></li></ul><div><span style="color:red">kept</span></div><img src="/i.png" alt="I"><pre><code>a<script>z</script></code></pre>`
func TestConverterNeverLeaksRawHtml(t *testing.T) {
converter := markdown.NewConverter()
for _, format := range []domainArticle.BodyFormat{domainArticle.BodyFormatMarkdown, domainArticle.BodyFormatJSON} {
got, err := converter.Render(hostileBody, format)
if err != nil {
t.Fatalf("%s: unexpected error: %v", format, err)
}
for _, forbidden := range []string{"<script", "<style", "<iframe", "<span", "alert(1)", "p{}"} {
if strings.Contains(got, forbidden) {
t.Errorf("%s output leaks %q: %s", format, forbidden, got)
}
}
}
}
func TestJsonConverterCombinedDocument(t *testing.T) {
converter := markdown.NewConverter()
got, err := converter.Render(hostileBody, domainArticle.BodyFormatJSON)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var blocks []map[string]any
if err := json.Unmarshal([]byte(got), &blocks); err != nil {
t.Fatalf("body is not a JSON array: %v", err)
}
var types []string
for _, b := range blocks {
types = append(types, b["type"].(string))
}
expected := []string{"heading", "paragraph", "list", "paragraph", "image", "code"}
if !reflect.DeepEqual(types, expected) {
t.Errorf("expected block types %v, got %v", expected, types)
}
}
func TestJsonConverterEmptyBodyIsArray(t *testing.T) {
converter := markdown.NewConverter()
for _, source := range []string{"", " ", "<script>x</script>"} {
got, err := converter.Render(source, domainArticle.BodyFormatJSON)
if err != nil || got != "[]" {
t.Errorf("%q: expected [] got %q, %v", source, got, err)
}
}
}
func TestMarkdownConverterCodeBlockText(t *testing.T) {
converter := markdown.NewConverter()
examples := []struct{ name, html, markdown string }{
{"text node kept", "<pre><code>x</code></pre>", "```\nx\n```"},
{"skipped element dropped", "<pre><code>a<script>z</script>b</code></pre>", "```\nab\n```"},
{"style dropped", "<pre><code>a<style>z</style></code></pre>", "```\na\n```"},
{"break becomes newline", "<pre><code>a<br>b</code></pre>", "```\na\nb\n```"},
{"nested element text kept", "<pre><code>a<span>b</span>c</code></pre>", "```\nabc\n```"},
{"trailing newline trimmed", "<pre><code>a\n</code></pre>", "```\na\n```"},
{"fence grows around backticks", "<pre><code>```\nx</code></pre>", "````\n```\nx\n````"},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown)
if err != nil || got != example.markdown {
t.Errorf("expected %q, got %q, %v", example.markdown, got, err)
}
})
}
}
+4 -4
View File
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+58
View File
@@ -0,0 +1,58 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"testing"
)
func TestJsonBlocksConverter(t *testing.T) {
converter := markdown.NewConverter()
paragraphA := `{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}`
listOfA := `[{"type":"list","ordered":false,"items":[[` + paragraphA + `]]}]`
examples := []struct {
name, html, json string
}{
{"h1", "<h1>T</h1>", `[{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}]`},
{"h6", "<h6>S</h6>", `[{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}]`},
{"paragraph", "<p>plain</p>", `[{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}]`},
{"empty paragraph", "<p></p>", `[{"type":"paragraph","children":[]}]`},
{"bold", "<p><strong>b</strong></p>", `[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}]`},
{"italic", "<p><em>i</em></p>", `[{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}]`},
{"link", `<p><a href="https://example.com/x">l</a></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}]`},
{"nested marks outermost first", `<p><a href="/a"><strong><em>x</em></strong></a></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}]`},
{"line break", "<p>a<br>b</p>", `[{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}]`},
{"bullet list", "<ul><li><p>a</p></li><li><p>b</p></li></ul>", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}]`},
{"ordered list", "<ol><li><p>a</p></li></ol>", `[{"type":"list","ordered":true,"items":[[` + paragraphA + `]]}]`},
{"bare list item", "<ul><li>a</li></ul>", listOfA},
{"nested list", "<ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul>", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `,{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"n","marks":[]}]}]]}]]}]`},
{"data-indent ignored", `<ul><li data-indent="2"><p>a</p></li></ul>`, listOfA},
{"blockquote", "<blockquote><p>q</p></blockquote>", `[{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}]`},
{"code with language", `<pre><code class="language-go">x := 1</code></pre>`, `[{"type":"code","language":"go","text":"x := 1"}]`},
{"code without language", "<pre><code>raw</code></pre>", `[{"type":"code","language":"","text":"raw"}]`},
{"rule", "<hr>", `[{"type":"hr"}]`},
{"image with alt", `<img src="/static/uploadedImages/a.png" alt="A">`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}]`},
{"image without alt", `<img src="/static/uploadedImages/a.png">`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":""}]`},
{"image splits paragraph", `<p>x<img src="/i.png" alt="I">y</p>`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}]`},
{"span stripped", `<p>x<span style="color:red">y</span></p>`, `[{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}]`},
{"inline code degrades", "<p>use <code>go test</code></p>", `[{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}]`},
{"script and style removed", "<p>ok</p><script>alert(1)</script><style>p{}</style>", `[{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}]`},
{"empty html", "", `[]`},
{"centered paragraph", `<p style="text-align: center">a</p>`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}]`},
{"right aligned heading", `<h2 style="text-align:right">T</h2>`, `[{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}]`},
{"justified paragraph", `<p style="color:red; text-align: JUSTIFY">a</p>`, `[{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}]`},
{"left alignment omitted", `<p style="text-align: left">a</p>`, `[` + paragraphA + `]`},
{"alignment skips the image inside a paragraph", `<p style="text-align: center">x<img src="/i.png" alt="I">y</p>`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","align":"center","children":[{"type":"text","text":"y","marks":[]}]}]`},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatJSON)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != example.json {
t.Errorf("expected %s, got %s", example.json, got)
}
})
}
}
+89
View File
@@ -0,0 +1,89 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/markdown"
"errors"
"testing"
)
func TestMarkdownConverterFidelity(t *testing.T) {
converter := markdown.NewConverter()
examples := []struct {
name, html, markdown string
}{
{"headings", "<h1>T</h1><h3>S</h3>", "# T\n\n### S"},
{"bold and italic", "<p><strong>b</strong> <em>i</em></p>", "**b** *i*"},
{"link", `<p><a href="https://example.com/x">link</a></p>`, "[link](https://example.com/x)"},
{"bullet list", "<ul><li><p>a</p></li><li><p>b</p></li></ul>", "- a\n- b"},
{"ordered list", "<ol><li><p>a</p></li><li><p>b</p></li></ol>", "1. a\n2. b"},
{"data-indent dropped", `<ul><li data-indent="2"><p>a</p></li></ul>`, "- a"},
{"nested list", "<ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul>", "- a\n - n"},
{"blockquote", "<blockquote><p>q</p></blockquote>", "> q"},
{"code block", `<pre><code class="language-go">x := 1</code></pre>`, "```go\nx := 1\n```"},
{"rule", "<hr>", "---"},
{"image", `<img src="/static/uploadedImages/a.png" alt="A">`, "![A](/static/uploadedImages/a.png)"},
{"script and style removed", "<p>ok</p><script>alert(1)</script><style>p{}</style>", "ok"},
{"unsupported span stripped", `<p>x<span style="color:red">y</span></p>`, "xy"},
{"inline code", "<p>use <code>go test</code></p>", "use `go test`"},
{"empty body", "", ""},
{"line break", "<p>a<br>b</p>", "a\\\nb"},
{"literal angle bracket is escaped", "<p>&lt;script&gt;</p>", "\\<script>"},
{"javascript link dropped", `<p><a href="javascript:alert(1)">x</a></p>`, "x"},
{"bold keeps outer spaces", "<p>a<strong> b </strong>c</p>", "a **b** c"},
{"loose list item", "<ul><li><p>a</p><p>b</p></li></ul>", "- a\n\n b"},
{"inline code with edge backtick", "<p><code>`x</code></p>", "`` `x ``"},
{"code block keeps br and drops script", "<pre><code>a<br>b<script>z</script></code></pre>", "```\na\nb\n```"},
{"adjacent same marks merge", "<p><strong>a</strong><strong>b</strong></p>", "**ab**"},
{"adjacent different marks stay apart", "<p><strong>a</strong><em>b</em></p>", "**a***b*"},
{"two paragraphs", "<p>a</p><p>b</p>", "a\n\nb"},
{"centered paragraph", `<p style="text-align: center">hi <strong>b</strong></p>`, "<div style=\"text-align: center\">\n\nhi **b**\n\n</div>"},
{"right aligned heading", `<h2 style="text-align:right">T</h2>`, "<div style=\"text-align: right\">\n\n## T\n\n</div>"},
{"justified among other styles", `<p style="color:red; TEXT-ALIGN: Justify">a</p>`, "<div style=\"text-align: justify\">\n\na\n\n</div>"},
{"tiptap style with trailing semicolon", `<p style="text-align: center;">a</p>`, "<div style=\"text-align: center\">\n\na\n\n</div>"},
{"tiptap explicit left", `<p style="text-align: left;">a</p>`, "a"},
{"left alignment is the default", `<p style="text-align: left">a</p>`, "a"},
{"unknown alignment ignored", `<p style="text-align: start">a</p>`, "a"},
{"style without value ignored", `<p style="text-align">a</p>`, "a"},
{"empty aligned paragraph", `<p style="text-align: center"></p>`, ""},
{"aligned paragraph in list item", `<ul><li><p style="text-align: center">a</p></li></ul>`, "- <div style=\"text-align: center\">\n\n a\n\n </div>"},
{"aligned paragraph in blockquote", `<blockquote><p style="text-align: center">q</p></blockquote>`, "> <div style=\"text-align: center\">\n>\n> q\n>\n> </div>"},
{"alignment skips the image inside a paragraph", `<p style="text-align: center">x<img src="/i.png" alt="I">y</p>`, "<div style=\"text-align: center\">\n\nx\n\n</div>\n\n![I](/i.png)\n\n<div style=\"text-align: center\">\n\ny\n\n</div>"},
{"text characters are escaped", "<p>a * b _c_ `d` [e]</p>", "a \\* b \\_c\\_ \\`d\\` \\[e\\]"},
{"leading hash is escaped", "<p># not a heading</p>", "\\# not a heading"},
{"hash without space is kept", "<p>#tag</p>", "#tag"},
{"leading dash is escaped", "<p>- not a list</p>", "\\- not a list"},
{"leading plus is escaped", "<p>+ not a list</p>", "\\+ not a list"},
{"leading quote marker is escaped", "<p>&gt; not a quote</p>", "\\> not a quote"},
{"leading number is escaped", "<p>1. not a list</p>", "1\\. not a list"},
{"leading number with parenthesis is escaped", "<p>2) not a list</p>", "2\\) not a list"},
{"leading rule is escaped", "<p>---</p>", "\\---"},
{"leading equals is escaped", "<p>==</p>", "\\=="},
{"inner dash is kept", "<p>a - b</p>", "a - b"},
{"marker after a line break is escaped", "<p>a<br>- b</p>", "a\\\n\\- b"},
{"combined document", `<h1>Title</h1><p style="text-align: center">Intro <strong>bold</strong> and <em>it</em></p><ul><li><p>one</p></li><li><p>two</p></li></ul><img src="/static/uploadedImages/a.png" alt="A"><pre><code class="language-go">x := 1</code></pre><blockquote><p>quote</p></blockquote><hr>`, "# Title\n\n<div style=\"text-align: center\">\n\nIntro **bold** and *it*\n\n</div>\n\n- one\n- two\n\n![A](/static/uploadedImages/a.png)\n\n```go\nx := 1\n```\n\n> quote\n\n---"},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != example.markdown {
t.Errorf("expected %q, got %q", example.markdown, got)
}
})
}
}
func TestMarkdownConverterHtmlAndUnknownFormat(t *testing.T) {
converter := markdown.NewConverter()
html := "<p>a</p><script>x</script>"
got, err := converter.Render(html, domainArticle.BodyFormatHTML)
if err != nil || got != html {
t.Errorf("html must pass through unchanged, got %q, %v", got, err)
}
if _, err := converter.Render(html, domainArticle.BodyFormat("pdf")); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
t.Errorf("expected ErrUnsupportedBodyFormat, got %v", err)
}
}
+4 -4
View File
@@ -52,10 +52,10 @@ func newMediaEnv(t *testing.T) *mediaEnv {
container := &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
+4 -4
View File
@@ -126,11 +126,11 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
hasher := hashing.NewBcryptHasher()
+4 -4
View File
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+4 -4
View File
@@ -24,11 +24,11 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+4 -4
View File
@@ -132,10 +132,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
return &api.UseCases{
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
+4 -4
View File
@@ -23,11 +23,11 @@ import (
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
loginHandler := auth.NewLoginHandler(
+30 -30
View File
@@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
@@ -62,7 +62,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
@@ -84,8 +84,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticle(1)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(1, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -96,8 +96,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_2", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetArticle(2)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(2, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -108,8 +108,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetArticle(9)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticle(9, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -117,8 +117,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
result, err := uc.GetOnlineArticle(1)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -129,8 +129,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_2", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetOnlineArticle(2)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -138,8 +138,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
_, err := uc.GetOnlineArticle(9)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(9, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -155,7 +155,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
t.Run("Backyard_Renovation_Update", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticleByName("Backyard Renovation Update")
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -167,7 +167,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) {
t.Run("Unknown_Title", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo)
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticleByName("Unknown Title")
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
@@ -198,8 +198,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListOnlineArticles", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListOnlineArticles()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -208,8 +208,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListAllArticles", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo)
result, err := uc.ListAllArticles()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListAllArticles(article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -219,9 +219,9 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo)
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles()
result, err := uc.ListOnlineArticles(article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -242,8 +242,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
result, err := uc.UpdateBody(1, "New body")
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateBody(1, "New body", article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -254,8 +254,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
_, err := uc.UpdateBody(9, "New body")
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateBody(9, "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -263,8 +263,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
result, err := uc.UpdateIsOnline(1, true)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateIsOnline(1, true, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -275,8 +275,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
_, err := uc.UpdateIsOnline(9, true)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateIsOnline(9, true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
+4 -4
View File
@@ -21,11 +21,11 @@ import (
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+4 -4
View File
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
getUserUseCase,
)
+3
View File
@@ -10,6 +10,7 @@
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
@@ -425,6 +426,8 @@
"@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="],
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="],
"@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="],
+1
View File
@@ -21,6 +21,7 @@
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
+2
View File
@@ -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<Editor | null>(null)
const extensions = [
ArticleIndent,
ArticleTextAlign,
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
+1 -1
View File
@@ -120,7 +120,7 @@ async function onSubmit() {
formAlert.value = ''
submitting.value = true
try {
const article = await api.request<Article>('/v1/article/', {
const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }),
@@ -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<HTMLButtonElement>('.article-editor button')
return buttons[FIRST_ALIGN_BUTTON + ALIGNMENTS.indexOf(name)] ?? null
}
it.each(ALIGNMENTS)('keeps the %s button enabled', async (name) => {
await mountArticleEditor()
const button = alignButton(name)
expect(button).not.toBeNull()
expect(button?.disabled).toBe(false)
})
it('writes the alignment into the body when a button is clicked', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
alignButton('center')?.click()
await flushPromises()
expect(editor.props('modelValue')).toContain('text-align: center')
})
})
describe('insertImage stamps the inserted image with its numeric id', () => {
it('renders data-image-id on the img and emits the updated body', async () => {
const wrapper = await mountArticleEditor()
@@ -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 }),
}),
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const request = vi.fn()
vi.mock('@/composables/useApi', () => ({
useApi: () => ({ request }),
}))
import { useArticles } from '@/composables/useArticles'
describe('useArticles', () => {
beforeEach(() => {
request.mockReset()
})
it('asks the API for html bodies when listing articles', async () => {
request.mockResolvedValue([])
await useArticles().listArticles()
expect(request).toHaveBeenCalledWith('/v1/article/?format=html')
})
})
+1 -1
View File
@@ -79,7 +79,7 @@ export function useArticleDraft(articleId: number) {
async function save(): Promise<SaveOutcome> {
businessState.value = 'saving'
try {
await request<Article>(`/v1/article/${articleId}`, {
await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: body.value }),
+1 -1
View File
@@ -13,7 +13,7 @@ export function useArticles() {
const { request } = useApi()
function listArticles(): Promise<Article[]> {
return request<Article[]>('/v1/article/')
return request<Article[]>('/v1/article/?format=html')
}
return { listArticles }
+2 -2
View File
@@ -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') {
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { ArticleIndent } from '../articleIndent'
import { ArticleTextAlign } from '../articleAlign'
function createEditor(content: string) {
return new Editor({
extensions: [StarterKit, ArticleIndent, ArticleTextAlign],
content,
})
}
describe('Aligning text writes text-align on paragraphs and headings', () => {
it.each(['center', 'right', 'justify'])('can align a paragraph to the %s', (align) => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
expect(editor.can().setTextAlign(align)).toBe(true)
editor.commands.setTextAlign(align)
expect(editor.getHTML()).toContain(`<p style="text-align: ${align};">Hello</p>`)
expect(editor.isActive({ textAlign: align })).toBe(true)
editor.destroy()
})
it('leaves unaligned paragraphs and headings without a style', () => {
const editor = createEditor('<h2>Title</h2><p>Hello</p>')
expect(editor.getHTML()).toBe('<h2>Title</h2><p>Hello</p>')
editor.destroy()
})
it('aligns a heading', () => {
const editor = createEditor('<h2>Title</h2>')
editor.commands.setTextSelection(2)
editor.commands.setTextAlign('right')
expect(editor.getHTML()).toContain('<h2 style="text-align: right;">Title</h2>')
editor.destroy()
})
it('returns to left alignment', () => {
const editor = createEditor('<p style="text-align: center">Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.setTextAlign('left')
expect(editor.isActive({ textAlign: 'center' })).toBe(false)
expect(editor.getHTML()).not.toContain('center')
editor.destroy()
})
it('loads stored alignment back from HTML', () => {
const editor = createEditor('<p style="text-align: justify">Hello</p>')
editor.commands.setTextSelection(2)
expect(editor.isActive({ textAlign: 'justify' })).toBe(true)
editor.destroy()
})
it('keeps alignment and indentation together', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
editor.commands.setTextAlign('center')
expect(editor.getHTML()).toContain('data-indent="1"')
expect(editor.getHTML()).toContain('text-align: center')
editor.destroy()
})
})
+8
View File
@@ -0,0 +1,8 @@
import TextAlign from '@tiptap/extension-text-align'
export const ALIGN_TYPES = ['heading', 'paragraph']
export const ArticleTextAlign = TextAlign.configure({
types: ALIGN_TYPES,
alignments: ['left', 'center', 'right', 'justify'],
})
+1 -1
View File
@@ -188,7 +188,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true)
try {
const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}`,
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
articles.value = articles.value.map((existing) =>
+2 -2
View File
@@ -65,7 +65,7 @@ async function loadArticle() {
}, SKELETON_DELAY_MS)
try {
const result = await request<Article>(`/v1/article/${articleId}`)
const result = await request<Article>(`/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<Article>(
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
article.value = updated
+5 -4
View File
@@ -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),
},
},
+26 -26
View File
@@ -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),
})
@@ -191,7 +191,7 @@ const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused:
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
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,
}
@@ -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') {
+37 -37
View File
@@ -147,7 +147,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => {
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: '<p>Some body</p>' })),
})
@@ -454,7 +454,7 @@ describe('Preview shows the rendered title and body without the editing surface'
describe('Offline blocks Save and Publish, and recovers when the connection returns', () => {
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<ReturnType<typeof mount>>) {
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',
)
@@ -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') {
@@ -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') {
+21 -21
View File
@@ -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')
})
@@ -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')