134 lines
6.9 KiB
Markdown
134 lines
6.9 KiB
Markdown
# 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.
|
|
|
|
`PATCH /v1/article/{id}` takes `body`, `site_id` and `author_ids` (the whole ordered list of author ids, see `docs/api.md`), and nothing else. The request must contain at least one of them, and `null` for either answers `400`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body.
|
|
|
|
## 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`.
|
|
|
|
## Pagination, search and counts
|
|
|
|
`GET /v1/article/` always answers one page inside an envelope, with or without query parameters:
|
|
|
|
```json
|
|
{
|
|
"items": [],
|
|
"total": 30,
|
|
"page": 1,
|
|
"size": 25,
|
|
"counts": { "all": 30, "online": 30, "draft": 0 }
|
|
}
|
|
```
|
|
|
|
| Parameter | Values | Default |
|
|
| --- | --- | --- |
|
|
| `page` | integer >= 1 | `1` |
|
|
| `size` | `5`, `25`, `50` or `100` | `25` |
|
|
| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none |
|
|
| `status` | `online` or `draft` | none |
|
|
| `site` | the `key` of a site; an unknown key gives an empty page, an empty value is `400` | none |
|
|
| `sort` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc` (lowercase, for example `title:asc`) | `updated_at:desc` |
|
|
|
|
- `items` is `[]` when empty, never `null`. Each item is the article object below.
|
|
- Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap.
|
|
- `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied.
|
|
- `counts` ignores `q` and `status`, so tab counts do not move while searching. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
|
|
- A page beyond the last answers `200` with `items: []` and the real `total`.
|
|
- An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first.
|
|
|
|
Breaking change: this route used to answer a bare JSON array. Clients must read the `items` property.
|
|
|
|
## 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,
|
|
"site_id": 1,
|
|
"authors": [],
|
|
"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 |
|