229 lines
16 KiB
Markdown
229 lines
16 KiB
Markdown
# Reading articles
|
|
|
|
Versions, translations, `revision`, `if_revision`, capabilities and the story move: [article-localization.md](article-localization.md).
|
|
|
|
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 `title`, `body`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`), the SEO fields listed below and the optional `if_revision`, and nothing else. The request must contain at least one of them (`if_revision` alone is not an update), 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`
|
|
|
|
Anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. A verified caller also sees the drafts that `content.read` covers: every draft at `all`, only their own at `own`, and none without the permission. The `all`, `online` and `draft` counts follow the same visible set.
|
|
|
|
## 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,
|
|
"created_by": 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",
|
|
"lang": "fr-CA",
|
|
"story_id": 5
|
|
}
|
|
```
|
|
|
|
`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only on `PATCH`, where `lang` and `story_id` are ignored. `POST /v1/article/` takes `title`, `body`, and optionally `lang`, `site` (a site key; the default site when absent or `null`), `story_id`, `source_article_id` and `copy` only as described in [article-localization.md](article-localization.md), which also documents `revision`, `capabilities`, `translations` and `source`, the additive keys of every article read. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed.
|
|
|
|
`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish.
|
|
|
|
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 and language (`site_id`, `lang`, `slug`), never globally; unassigned articles (`lang` `""`) share the empty language, so they keep the per-site rule. 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 `<slug>-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 `<head>`. Online articles are public; a draft needs a verified session or API key whose role may read it (see [Content permissions](../api.md#content-permissions)) 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
|
|
<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 |
|
|
| `400` | `invalid_site_key` for a `site` of `POST /v1/article/` that is not a valid site key |
|
|
| `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: <first free slug>` (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 |
|