feat: SEO backend output (2/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 21:48:55 +02:00
1 parent 0eb46b0d74
commit eb9876fde3
32 files changed
+3859 -65

No files matched your search

+238 -5
View File
@@ -137,6 +137,108 @@ const ARTICLE_LIST_QUERY: ApiField[] = [
required: false,
rule: 'The key of a site. Only its articles are listed. An unknown key answers an empty page.',
},
{
name: 'seo',
type: 'string',
required: false,
rule: 'ready, suggestions or hidden. Only the articles with that SEO status are listed, before paging; counts stay unfiltered. Any other value is refused.',
},
]
const SITEMAP_QUERY: ApiField[] = [
{
name: 'page',
type: 'number',
required: false,
rule: 'A whole number from 1. Default 1.',
},
{
name: 'size',
type: 'number',
required: false,
rule: '25, 50, 100 or 500. Default 100.',
},
]
const CHECK_BODY: ApiField[] = [
{
name: 'body',
type: 'string',
required: false,
rule: 'Replaces the saved body for this check only. null is refused.',
},
{
name: 'slug',
type: 'string',
required: false,
rule: 'Same rules as PATCH. An empty string regenerates it from the title. null is refused.',
},
{
name: 'excerpt',
type: 'string',
required: false,
rule: 'Up to 300 characters. null is refused.',
},
{
name: 'seo_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. null is refused.',
},
{
name: 'seo_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. null is refused.',
},
{
name: 'cover_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image. null means no cover.',
},
{
name: 'cover_image_alt',
type: 'string',
required: false,
rule: 'Up to 200 characters. null is refused.',
},
{
name: 'social_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. null is refused.',
},
{
name: 'social_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. null is refused.',
},
{
name: 'social_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image. null means no social image.',
},
{
name: 'canonical_url',
type: 'string',
required: false,
rule: 'An absolute https address with a host, up to 2048 characters. null is refused.',
},
{
name: 'noindex',
type: 'boolean',
required: false,
rule: 'null is refused.',
},
{
name: 'seo_dismissed',
type: 'string[]',
required: false,
rule: 'Dismissed suggestion codes, replacing the saved list for this check only. null is refused.',
},
]
const MEDIA_QUERY: ApiField[] = [
@@ -197,6 +299,90 @@ const article = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
const SAMPLE_SITE_KEY = 'renew-blog'
const resolvedSeo = () => ({
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
canonical_url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
robots: 'index, follow',
og: {
type: 'article',
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
image_alt: 'A tiled bathroom wall',
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
site_name: 'Renew',
locale: 'en',
},
twitter: {
card: 'summary_large_image',
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
image_alt: 'A tiled bathroom wall',
},
image: {
url: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
alt: 'A tiled bathroom wall',
source: 'cover',
},
authors: [
{
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
same_as: ['https://github.com/ines-duarte'],
},
],
published_at: '2026-03-12T09:14:00Z',
modified_at: '2026-09-10T07:00:00Z',
fallbacks: { title: 'title', description: 'excerpt', image: 'cover', canonical: 'site' },
json_ld: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: ARTICLE_TITLE,
description: 'What drives the price of a bathroom renovation.',
image: ['https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg'],
author: [
{
'@type': 'Person',
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
sameAs: ['https://github.com/ines-duarte'],
},
],
datePublished: '2026-03-12T09:14:00Z',
dateModified: '2026-09-10T07:00:00Z',
mainEntityOfPage: {
'@type': 'WebPage',
'@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
},
},
})
const seoCheck = () => ({
status: 'suggestions',
suggestions: [
{ code: 'desc_short', field: 'seo_description', params: { length: 49, min: 70 } },
{ code: 'no_au', field: 'author_ids', params: {} },
],
slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' },
})
const sitemapPage = () => [
{
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
modified_at: '2026-09-10T07:00:00Z',
noindex: false,
},
{
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
modified_at: '2026-09-09T07:00:00Z',
noindex: true,
},
]
const LIST_ITEM_OMITTED_KEYS = [
'seo_title',
'seo_description',
@@ -210,10 +396,13 @@ const LIST_ITEM_OMITTED_KEYS = [
'seo_dismissed',
]
const articleListItem = (overrides: Record<string, unknown> = {}) =>
Object.fromEntries(
const articleListItem = (overrides: Record<string, unknown> = {}) => ({
...Object.fromEntries(
Object.entries(article(overrides)).filter(([key]) => !LIST_ITEM_OMITTED_KEYS.includes(key)),
)
),
seo_status: 'suggestions',
seo_suggestions: 2,
})
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
id: 4,
@@ -305,6 +494,8 @@ const articlePage = () => ({
slug: 'choosing-tiles-for-a-small-bathroom',
excerpt: '',
updated_at: '2026-09-09T07:00:00Z',
seo_status: 'ready',
seo_suggestions: 0,
}),
],
total: 30,
@@ -405,6 +596,15 @@ export const API_GROUPS: ApiGroup[] = [
json(article()),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-seo',
'GET',
'/v1/article/{id}/seo',
'Get the resolved SEO object of an article: title, description, canonical address, robots, Open Graph, Twitter, image, authors, dates and ready JSON-LD. Drafts answer 404 unless the caller is verified.',
'Public',
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
json(resolvedSeo()),
),
endpoint(
'art-create',
'POST',
@@ -597,6 +797,28 @@ export const API_GROUPS: ApiGroup[] = [
NO_CONTENT_RESPONSE,
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-check',
'POST',
'/v1/article/{id}/seo/check',
'Check the SEO of an article without saving anything. Send {} for the saved state, or fields that replace the saved values for this check only.',
'Verified user',
verified([
e(400, BODY_MISMATCH),
e(400, 'invalid_slug'),
e(400, 'image_not_found'),
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Article not found'),
e(422, 'invalid_url'),
]),
json(seoCheck()),
{
bodyKind: 'json',
body: CHECK_BODY,
bodySample: { seo_title: 'Bathroom costs, explained', noindex: false },
},
),
],
},
{
@@ -977,7 +1199,7 @@ export const API_GROUPS: ApiGroup[] = [
key: 'sites',
title: 'Sites',
description:
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.',
endpoints: [
endpoint(
'site-list',
@@ -1004,6 +1226,16 @@ export const API_GROUPS: ApiGroup[] = [
}),
]),
),
endpoint(
'site-sitemap',
'GET',
'/v1/site/{key}/sitemap',
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.',
'Public',
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
json(sitemapPage()),
{ query: SITEMAP_QUERY },
),
],
},
{
@@ -1206,7 +1438,8 @@ export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\
export const AUTH_EXAMPLE_PATH = '/v1/auth/me'
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
return item.pathId === null ? item.path : item.path.replace('{id}', String(item.pathId))
const keyed = item.path.replace('{key}', SAMPLE_SITE_KEY)
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
}
export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExamples {