feat: articles format option with markdown new default
This commit is contained in:
1 parent
68401d66e0
commit
7e9bd9ba30
61 files changed
+2269
-243
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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"> |  |
|
||||
| <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>> 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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 " + ")"
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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">`, ""},
|
||||
{"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><script></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\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>> 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\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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
})
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in new issue
Block a user