Feature: Article body content negotiation on read As a headless frontend developer I want article routes to return the body as markdown (default), html or a typed json block array So that my client can render it without handling editor HTML Background: Given article bodies are stored as Tiptap HTML And storage, the POST /v1/article body and the PATCH /v1/article/{id} body stay HTML and are unchanged And internal/domain/article defines BodyFormat with the values "markdown", "html" and "json" And internal/domain/article defines the port BodyRenderer and the sentinel ErrUnsupportedBodyFormat And ParseBodyFormat matches case-insensitively, treats an empty value as "markdown", and returns ErrUnsupportedBodyFormat otherwise And the use cases receive the BodyRenderer through their constructors, the handler only reads the "format" query value, and only api/dependecyInjection.go builds the converter And every article object in a response has the snake_case fields id, title, body, body_format, is_online, created_at, updated_at And body_format is "markdown", "html" or "json" and always names the format of body And for markdown and html, body is a JSON string; for json, body is a JSON array of blocks (not an escaped string) And the covered routes are GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish and PATCH /v1/article/{id}/unpublish And visibility is unchanged: anonymous and unverified callers see only online articles, and an offline article reads as 404 "Article not found" And an unsupported format answers 400 with apperr.ArticleBodyFormatErrorMessage as plain text, before any article lookup or write And an invalid id answers apperr.IdUint32ErrorMessage first, ahead of the format check And a repeated "format" parameter uses its first value And a renderer failure answers 500 "Internal server error" And article 1 is online, titled "Backyard Renovation Update", with body "

Plan

Replace the old deck.

" Scenario Outline: Single read negotiates the body format When an anonymous client sends GET /v1/article/1 Then the response status is 200 And "body" is And "body_format" is "" Examples: | query | body | body_format | | | "## Plan\n\nReplace the **old** deck." | markdown | | ?format= | "## Plan\n\nReplace the **old** deck." | markdown | | ?format=markdown | "## Plan\n\nReplace the **old** deck." | markdown | | ?format=MarkDown | "## Plan\n\nReplace the **old** deck." | markdown | | ?format=html | "

Plan

Replace the old deck.

" | html | | ?format=HTML | "

Plan

Replace the old deck.

" | html | | ?format=json | [{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}] | json | | ?format=JSON | the same array as ?format=json | json | Scenario: List read negotiates every element Given the repository also holds online article 3 with body "

Second

" When an anonymous client sends GET /v1/article/ Then every element has body_format "markdown" and article 3 has body "Second" When an anonymous client sends GET /v1/article/?format=html Then every element has body_format "html" and its stored HTML as body When an anonymous client sends GET /v1/article/?format=json Then every element has body_format "json" and article 3 has body [{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}] Scenario Outline: An unsupported format is rejected When an anonymous client sends GET Then the response status is 400 And the response body is apperr.ArticleBodyFormatErrorMessage followed by a newline And no use case reads or renders an article Examples: | path | | /v1/article/1?format=pdf | | /v1/article/?format=xml | | /v1/article/9999?format=pdf | | /v1/article/1?format=md | Scenario: An invalid id is reported before an invalid format When an anonymous client sends GET /v1/article/abc?format=pdf Then the response status is 400 And the response body is apperr.IdUint32ErrorMessage followed by a newline Scenario: The first repeated format parameter wins When an anonymous client sends GET /v1/article/1?format=html&format=json Then "body_format" is "html" Scenario: Offline articles stay hidden whatever the format Given article 2 is offline When an anonymous client sends GET /v1/article/2?format=json Then the response status is 404 and the body is "Article not found" Scenario Outline: Authenticated routes negotiate the response body Given a valid jwt cookie for a verified user with id 1 When the client sends with Then the response status is And "body_format" is "" And the stored body is the HTML that was sent, never markdown or json Examples: | method | path | request_body | status | body_format | | POST | /v1/article/ | {"title":"Deck","body":"

A

"} | 201 | markdown | | POST | /v1/article/?format=html | {"title":"Deck","body":"

A

"} | 201 | html | | POST | /v1/article/?format=json | {"title":"Deck","body":"

A

"} | 201 | json | | PATCH | /v1/article/1 | {"body":"

B

"} | 200 | markdown | | PATCH | /v1/article/1?format=json | {"body":"

B

"} | 200 | json | | PATCH | /v1/article/1/publish | | 200 | markdown | | PATCH | /v1/article/1/unpublish?format=html | | 200 | html | Scenario: An unsupported format on a write route changes nothing Given a valid jwt cookie for a verified user with id 1 When the client sends PATCH /v1/article/1?format=pdf with {"body":"

B

"} Then the response status is 400 And the stored body of article 1 is unchanged When the client sends POST /v1/article/?format=pdf with {"title":"Deck","body":"

A

"} Then the response status is 400 and no article is created Scenario: The authenticated list includes offline articles in the negotiated format Given a valid jwt cookie for a verified user and offline article 2 When the client sends GET /v1/article/?format=json Then the array contains articles 1 and 2 with body_format "json" Scenario: A converter failure answers 500 Given the BodyRenderer fails on its next call When an anonymous client sends GET /v1/article/1 Then the response status is 500 and the body is "Internal server error" Scenario Outline: Markdown fidelity for Tiptap output Given the stored body is When converted with the format "markdown" Then the body is Examples: | html | markdown | |

T

S

| # T\n\n### S | |

b i

| **b** *i* | |

link

| [link](https://example.com/x) | |
  • a

  • b

| - a\n- b | |
  1. a

  2. b

| 1. a\n2. b | |
  • a

| - a | |

q

| > q | |
x := 1
| ```go\nx := 1\n``` | |
| --- | | A | ![A](/static/uploadedImages/a.png) | |

ok

| ok | |

xy

| xy | |

use go test

| use `go test` | Scenario Outline: Json block shapes, one row per block and mark type Given the stored body is When converted with the format "json" Then the body is Examples: | html | json | |

T

| [{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}] | |
S
| [{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}] | |

plain

| [{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}] | |

| [{"type":"paragraph","children":[]}] | |

b

| [{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}] | |

i

| [{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}] | |

l

| [{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}] | |

x

| [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}] | |

a
b

| [{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}] | |
  • a

  • b

| [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}] | |
  1. a

| [{"type":"list","ordered":true,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}]]}] | |
  • bare
| [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"bare","marks":[]}]}]]}] | |
  • a

    • n

| a list item whose blocks are the paragraph "a" followed by a nested list block with ordered false and the item "n" | |
  • a

| the same output as without data-indent | |

q

| [{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}] | |
x := 1
| [{"type":"code","language":"go","text":"x := 1"}] | |
raw
| [{"type":"code","language":"","text":"raw"}] | |
| [{"type":"hr"}] | | A | [{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}] | | | [{"type":"image","src":"/static/uploadedImages/a.png","alt":""}] | |

xIy

| [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}] | |

xy

| [{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}] | |

use go test

| [{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}] | |

ok

| [{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}] | Scenario Outline: Text alignment survives the conversion Given the stored body is When converted with the format "markdown" Then the body is When converted with the format "json" Then the body is Examples: | html | markdown | json | |

a

|
a
| [{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}] | |

T

|
## T
| [{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}] | |

a

|
a
| [{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}] | |

a

| a | [{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}] | |

a

| a | the same output as the left row | |

| an empty string | [{"type":"paragraph","align":"center","children":[]}] | Scenario Outline: Plain text that looks like markdown stays plain text Given the stored body is When converted with the format "markdown" Then the body is Examples: | html | markdown | |

# not a heading

| \# not a heading | |

#tag

| #tag | |

- not a list

| \- not a list | |

+ not a list

| \+ not a list | |

> not a quote

| \> not a quote | |

1. not a list

| 1\. not a list | |

2) not a list

| 2\) not a list | |

---

| \--- | |

a - b

| a - b | |

a * b _c_

| a \* b \_c\_ | |

a
- b

| a\n\- b | Scenario: The editor lets the author align text Given the article editor registers the TextAlign extension for paragraphs and headings Then the left, center, right and justify toolbar buttons are enabled And clicking center writes style="text-align: center;" on the current paragraph or heading And paragraphs and headings that were never aligned carry no style attribute Scenario: The SPA asks for html explicitly Given no visual change is expected, so docs/design is not consulted When EditorView loads an article, or ArticleView or HomeView load the list Then each request is /v1/article/{id}?format=html or /v1/article/?format=html And the draft save PATCH, the publish and unpublish PATCH and the create POST also carry ?format=html, so a converter failure cannot fail a save And the editor and the image counts receive HTML exactly as before Scenario: The documentation reflects the contract Then docs/api has an article read page covering format, the markdown default, body_format, the json block schema, the 400 message and the breaking default change And web/src/views/IntegrationView.vue snippets show ?format And docs/releases has a note stating the default flip and that the SPA sends format=html