feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
+45
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
`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`
|
||||
|
||||
@@ -71,11 +71,52 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
|
||||
"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 `<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.
|
||||
|
||||
## 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.
|
||||
@@ -129,5 +170,8 @@ Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
|
||||
| --- | --- |
|
||||
| `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: <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 |
|
||||
Reference in new issue
Block a user