feat: SEO backend data (1/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 19:30:29 +02:00
1 parent cb1410f0b0
commit 0eb46b0d74
90 files changed
+6094 -350

No files matched your search

+140 -7
View File
@@ -180,11 +180,41 @@ const article = (overrides: Record<string, unknown> = {}) => ({
body_format: 'markdown',
is_online: true,
site_id: 1,
slug: 'how-we-price-a-bathroom-renovation',
excerpt: 'What drives the price of a bathroom renovation.',
seo_title: '',
seo_description: '',
cover_image_id: 4,
cover_image_alt: 'A tiled bathroom wall',
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',
...overrides,
})
const LIST_ITEM_OMITTED_KEYS = [
'seo_title',
'seo_description',
'cover_image_id',
'cover_image_alt',
'social_title',
'social_description',
'social_image_id',
'canonical_url',
'noindex',
'seo_dismissed',
]
const articleListItem = (overrides: Record<string, unknown> = {}) =>
Object.fromEntries(
Object.entries(article(overrides)).filter(([key]) => !LIST_ITEM_OMITTED_KEYS.includes(key)),
)
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
id: 4,
filename: 'north-wall.jpg',
@@ -211,6 +241,9 @@ const site = (overrides: Record<string, unknown> = {}) => ({
locale: 'en',
is_default: true,
article_count: 30,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: 'Notes from a renovation site.',
...overrides,
})
@@ -263,12 +296,14 @@ const ownerOrAdminErrors = (...own: ApiError[]) => [
const articlePage = () => ({
items: [
article(),
article({
articleListItem(),
articleListItem({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
slug: 'choosing-tiles-for-a-small-bathroom',
excerpt: '',
updated_at: '2026-09-09T07:00:00Z',
}),
],
@@ -417,13 +452,20 @@ export const API_GROUPS: ApiGroup[] = [
'art-update',
'PATCH',
'/v1/article/{id}',
'Update the body of an article, or move it to another site. Send body, site_id or both.',
'Update the body of an article, move it to another site, or edit its SEO fields. Send at least one field; absent fields are untouched.',
'Verified user',
verified(
articleErrors(
e(400, BODY_MISMATCH),
e(400, 'invalid_slug'),
e(400, 'image_not_found'),
...articleIdErrors,
e(404, 'site_not_found'),
e(
409,
'slug_taken (the X-Slug-Suggestion header holds the first free slug)',
),
e(422, 'invalid_url'),
),
),
json(article({ body: 'Updated text.', is_online: false })),
@@ -441,10 +483,88 @@ export const API_GROUPS: ApiGroup[] = [
name: 'site_id',
type: 'number',
required: false,
rule: 'The id of an existing site, 1 or more. null is refused. At least one of body and site_id is required.',
rule: 'The id of an existing site, 1 or more. null is refused.',
},
{
name: 'slug',
type: 'string',
required: false,
rule: 'Lowercase letters, digits and single dashes, 1 to 100 characters, unique on the site. An empty string regenerates it from the title. null is refused.',
},
{
name: 'excerpt',
type: 'string',
required: false,
rule: 'Up to 300 characters. An empty string clears it; null is refused.',
},
{
name: 'seo_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. An empty string clears it; null is refused.',
},
{
name: 'seo_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. An empty string clears it; null is refused.',
},
{
name: 'cover_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image, 1 or more. null removes the cover.',
},
{
name: 'cover_image_alt',
type: 'string',
required: false,
rule: 'Up to 200 characters. An empty string clears it; null is refused.',
},
{
name: 'social_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. An empty string clears it; null is refused.',
},
{
name: 'social_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. An empty string clears it; null is refused.',
},
{
name: 'social_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image, 1 or more. null removes the social image.',
},
{
name: 'canonical_url',
type: 'string',
required: false,
rule: 'An absolute https address with a host, up to 2048 characters. An empty string clears it; null is refused.',
},
{
name: 'noindex',
type: 'boolean',
required: false,
rule: 'Only a flag: the article stays online. null is refused.',
},
{
name: 'seo_dismissed',
type: 'string[]',
required: false,
rule: 'Dismissed suggestion codes: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Duplicates are collapsed; null is refused.',
},
],
bodySample: { body: 'Updated text.', site_id: 2 },
bodySample: {
body: 'Updated text.',
site_id: 2,
slug: 'bathroom-pricing',
excerpt: 'What drives the price.',
cover_image_id: 4,
},
},
),
endpoint(
@@ -602,10 +722,20 @@ export const API_GROUPS: ApiGroup[] = [
'med-use',
'GET',
'/v1/media/{id}/usage',
'List the articles that use an image.',
'List the articles, authors and sites that use an image, with the places where each one uses it.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json([{ id: 12, title: ARTICLE_TITLE, published: true }]),
json([
{
type: 'article',
id: 12,
title: ARTICLE_TITLE,
published: true,
fields: ['body', 'cover'],
},
{ type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] },
{ type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] },
]),
{ pathId: 4 },
),
endpoint(
@@ -868,6 +998,9 @@ export const API_GROUPS: ApiGroup[] = [
locale: null,
is_default: false,
article_count: 4,
site_name: '',
title_template: '%title% - %site%',
default_description: '',
}),
]),
),