# 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`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`) and the SEO fields listed below, and nothing else. The request must contain at least one of them, and `null` answers `400` except for `cover_image_id` and `social_image_id`. 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, "suggestions": 4 } } ``` | 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 | | `seo` | `ready`, `suggestions` or `hidden`; any other value, an empty one included, 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`, `status`, `site`, `seo`, `sort`, `page` and `size`, so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`. - Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not. - 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": [], "slug": "backyard-renovation-update", "excerpt": "", "seo_title": "", "seo_description": "", "cover_image_id": null, "cover_image_alt": "", "social_title": "", "social_description": "", "social_image_id": null, "canonical_url": "", "noindex": false, "seo_dismissed": [], "created_at": "2026-03-12T09:14:00Z", "updated_at": "2026-09-10T07:00:00Z" } ``` Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten. ## SEO fields | Field | Type | Limit and rule | | --- | --- | --- | | `slug` | string | `^[a-z0-9]+(-[a-z0-9]+)*$`, 1 to 100 characters, unique per site. `""` regenerates it from the title. | | `excerpt` | string | 300 characters | | `seo_title` | string | 120 characters | | `seo_description` | string | 320 characters | | `cover_image_id` | number or `null` | an existing image id (`0` is refused) | | `cover_image_alt` | string | 200 characters, independent of the cover | | `social_title` | string | 120 characters | | `social_description` | string | 320 characters | | `social_image_id` | number or `null` | an existing image id (`0` is refused) | | `canonical_url` | string | `""` or an absolute `https` URL with a host, at most 2048 characters | | `noindex` | boolean | only a flag: the article stays online | | `seo_dismissed` | array of strings | any of `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`; duplicates are collapsed, order kept | Limits are counted in characters (runes), not bytes. Absent fields are untouched; text fields and `noindex` are cleared with `""`, `false` and `[]`, the two image ids with `null`. ### The slug A slug is unique per site (`site_id`, `slug`), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site. Editing a slug that another article of the target site already uses (the new site when `site_id` is sent in the same request; moving an article also re-checks its current slug there) answers `409` with the plain-text body `slug_taken`, and the response header `X-Slug-Suggestion` holds the first free `-N` from 2 on that site, fitting 100 characters. `X-Slug-Suggestion` is set on this `409` only and on no other response. A request with `slug: ""` that regenerates onto another article's slug is refused the same way and never suffixed silently. Re-sending an article's own slug is not a conflict. A refused request changes nothing. Publishing, unpublishing and `noindex` never change a slug. A deleted article frees its slug. ## Resolved SEO object `GET /v1/article/{id}/seo` answers the data a frontend renders in its ``. Online articles are public; a draft needs a verified session or API key and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys: `title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` and `json_ld` (a ready schema.org `Article`). The fallback chain, first non-empty wins: | Output | Chain | `fallbacks` value | | --- | --- | --- | | `title` | `seo_title`, then the article title, then the site `title_template` (`%title%`, `%site%` replaced by `site_name`, result trimmed) | `seo_title`, `title` | | `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` | `seo_description`, `excerpt`, `body`, `site`, `none` | | `image` | `social_image_id`, `cover_image_id` (with `cover_image_alt`), the site `default_social_image_id`; `url` is the site `base_url` plus the image path, `""` and `none` when there is none | `social`, `cover`, `site`, `none` | | `canonical_url` | `canonical_url`, then site `base_url` + `/` + `slug` | `canonical_url`, `site` | | `robots` | `noindex, nofollow` when `noindex` is true, otherwise from the site `default_robots` (`index, follow` or `noindex, nofollow`) | none | | `og` and `twitter` title and description | `social_title` and `social_description`, then the resolved values | none | `og.type` is `article`, `og.locale` is the site locale (`""` when none), `twitter.card` is `summary_large_image` with an image and `summary` without. `published_at` is the creation time of an online article and `null` for a draft; `modified_at` is `updated_at`. `authors[].url` is the author website (`""` when none) and `same_as` lists the social link URLs only. `json_ld.headline` is the meta title without the site template, and `author`, `image` and `datePublished` are left out when empty. ## SEO status and suggestions `POST /v1/article/{id}/seo/check` and the list use one rule set. Soft ranges never block saving or publishing; lengths are counted in characters. | Code | Field | Fires when | `params` | | --- | --- | --- | --- | | `title_long` | `seo_title` | the meta title (`seo_title`, else the title) is over 60 characters | `length`, `max` | | `title_empty` | `seo_title` | the meta title is empty after trimming | none | | `desc_short` | `seo_description` | the resolved description is under 70 characters | `length`, `min` | | `desc_long` | `seo_description` | the resolved description is over 160 characters | `length`, `max` | | `cover_alt` | `cover_image_alt` | there is a cover and its alt text is empty | none | | `cover_none` | `cover_image_id` | there is no cover | none | | `slug_long` | `slug` | the slug is over 75 characters | `length`, `max` | | `no_au` | `author_ids` | no author is credited | none | | `no_h` | `body` | the text has at least 1000 characters and no `h2` to `h6` | none | | `body_alt` | `body` | an image of the body has no alt text | `count` | | `short` | `body` | the text has fewer than 300 characters | `length`, `min` | Suggestions come in that order. Codes in `seo_dismissed` are never reported or counted. The status is `hidden` when `noindex` is true, else `suggestions` when at least one suggestion remains, else `ready`. The list items carry `seo_status` and `seo_suggestions` (the count, also for a hidden article). ## Markdown output Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline. ### Text alignment Markdown has no syntax for alignment, so centered, right-aligned and justified paragraphs and headings are wrapped in a `div` with an inline style. The blank lines around the content make CommonMark parse the inside as markdown: ```markdown
A **centered** paragraph
``` Left alignment is the default and produces no wrapper. Your markdown renderer must allow raw HTML for the alignment to show (`html: true` in markdown-it); otherwise the wrapper tags appear as text, and a client that does not want them can request `?format=json` instead, where the alignment is a field. ## JSON block schema `body` is a native JSON array. Every block has a `type`. | Block | Fields | | --- | --- | | `heading` | `level` (1 to 6), `align` (only when `center`, `right` or `justify`), `children` (text nodes) | | `paragraph` | `align` (only when `center`, `right` or `justify`), `children` (text nodes, empty array for an empty paragraph) | | `list` | `ordered` (boolean), `items` (array of items, each an array of blocks) | | `blockquote` | `children` (blocks) | | `code` | `language` (empty string when unknown), `text` | | `hr` | none | | `image` | `src`, `alt` (empty string when missing) | Text node: `{"type":"text","text":"...","marks":[...]}`. `marks` is always an array, listed outermost first. A mark is `{"type":"bold"}`, `{"type":"italic"}` or `{"type":"link","href":"..."}`. A line break becomes a `"\n"` inside the text. Adjacent text with identical marks is merged. Inline code, underline, strike-through and other formatting degrade to plain text. An image inside a paragraph becomes its own block and splits the paragraph. An empty body is `[]`. Example for `

Plan

Replace the old deck.

`: ```json [ {"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]}, {"type":"paragraph","children":[ {"type":"text","text":"Replace the ","marks":[]}, {"type":"text","text":"old","marks":[{"type":"bold"}]}, {"type":"text","text":" deck.","marks":[]} ]} ] ``` ## Errors | Status | Body | | --- | --- | | `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id | | `400` | `The format must be one of: markdown, html, json.` for an unsupported format | | `400` | `invalid_slug`, `image_not_found` or the validator text for an over-long SEO field, `cover_image_id: 0` or an unknown `seo_dismissed` code | | `404` | `Article not found` | | `409` | `slug_taken`, with the header `X-Slug-Suggestion: ` (this response only) | | `422` | `invalid_url` for a `canonical_url` that is not an absolute `https` URL with a host | | `500` | `Internal server error` when the body cannot be converted |