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

+73 -12
View File
@@ -21,11 +21,13 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
Articles: [
'GET /v1/article/',
'GET /v1/article/{id}',
'GET /v1/article/{id}/seo',
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'PATCH /v1/article/{id}/unpublish',
'DELETE /v1/article/{id}',
'POST /v1/article/{id}/seo/check',
],
Media: [
'GET /v1/media/',
@@ -48,7 +50,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'GET /v1/auth/me',
],
Settings: ['GET /v1/settings'],
Sites: ['GET /v1/site/'],
Sites: ['GET /v1/site/', 'GET /v1/site/{key}/sitemap'],
Authors: ['GET /v1/author/'],
}
@@ -102,6 +104,8 @@ const ARTICLE_LIST_KEYS = [
'site_id',
'slug',
'excerpt',
'seo_status',
'seo_suggestions',
'created_at',
'updated_at',
]
@@ -203,13 +207,13 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
}
describe('Catalogue routes', () => {
it('holds exactly the 26 key-callable routes in 6 groups, in order', () => {
it('holds exactly the 29 key-callable routes in 6 groups, in order', () => {
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(26)
expect(allEndpoints()).toHaveLength(29)
})
it('holds the 25 session-only rows in 7 sub-groups', () => {
@@ -238,7 +242,7 @@ describe('Catalogue routes', () => {
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(6 + 26 + 25 + 6)
expect(described).toHaveLength(6 + 29 + 25 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
@@ -266,7 +270,7 @@ describe('Catalogue completeness', () => {
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(
`"${ORIGIN}${endpoint.path.replace('{id}', String(endpoint.pathId))}"`,
`"${ORIGIN}${endpoint.path.replace('{key}', 'renew-blog').replace('{id}', String(endpoint.pathId))}"`,
)
expect(examples.response).toBe(endpoint.response)
expect(examples.response.trim()).not.toBe('')
@@ -281,12 +285,17 @@ describe('Catalogue completeness', () => {
},
)
it('gives Public to the two article reads and Owner only to the invite routes', () => {
it('gives Public to the article reads, the resolved SEO and the sitemap, and Owner only to the invite routes', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Public')
.map(label),
).toEqual(['GET /v1/article/', 'GET /v1/article/{id}'])
).toEqual([
'GET /v1/article/',
'GET /v1/article/{id}',
'GET /v1/article/{id}/seo',
'GET /v1/site/{key}/sitemap',
])
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Owner only')
@@ -299,6 +308,53 @@ describe('Catalogue completeness', () => {
})
})
describe('SEO output routes', () => {
it('documents the seo list parameter and the two list fields', () => {
expect(find('GET /v1/article/').query.map((field) => field.name)).toContain('seo')
for (const item of parsed(find('GET /v1/article/')).items) {
expect(item).toHaveProperty('seo_status')
expect(item).toHaveProperty('seo_suggestions')
}
})
it('answers the resolved object with its twelve keys and no body', () => {
const resolved = parsed(find('GET /v1/article/{id}/seo'))
expect(keysOf(resolved)).toEqual(
[
'title',
'description',
'canonical_url',
'robots',
'og',
'twitter',
'image',
'authors',
'published_at',
'modified_at',
'fallbacks',
'json_ld',
].sort(),
)
})
it('answers the check with status, suggestions and slug and takes a JSON body', () => {
const checked = parsed(find('POST /v1/article/{id}/seo/check'))
expect(keysOf(checked)).toEqual(['slug', 'status', 'suggestions'])
const names = find('POST /v1/article/{id}/seo/check').body.map((field) => field.name)
expect(names).toContain('body')
expect(names).not.toContain('site_id')
expect(names).not.toContain('author_ids')
})
it('answers the sitemap as an array of url, modified_at and noindex with a page and a size', () => {
const sitemap = find('GET /v1/site/{key}/sitemap')
expect(sitemap.query.map((field) => field.name)).toEqual(['page', 'size'])
for (const item of parsed(sitemap)) {
expect(keysOf(item)).toEqual(['modified_at', 'noindex', 'url'])
}
})
})
describe('Owner or admin purge routes', () => {
it('gives Owner or admin to the four purge routes only', () => {
expect(
@@ -542,13 +598,15 @@ describe('Request bodies and query parameters', () => {
}
})
it('lists format on every article route', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
it('lists format on every article route except the SEO routes', () => {
for (const endpoint of API_GROUPS[0]!.endpoints.filter(
(item) => !item.path.includes('/seo'),
)) {
expect(endpoint.query.map((param) => param.name)).toContain('format')
}
})
it('lists page, size, q, status, sort and site on the article list only', () => {
it('lists page, size, q, status, sort, site and seo on the article list only', () => {
expect(find('GET /v1/article/').query.map((param) => param.name)).toEqual([
'format',
'page',
@@ -557,6 +615,7 @@ describe('Request bodies and query parameters', () => {
'status',
'sort',
'site',
'seo',
])
expect(find('GET /v1/article/{id}').query.map((param) => param.name)).toEqual(['format'])
})
@@ -652,8 +711,10 @@ describe('Per-endpoint error captions use the real strings', () => {
expect(find(route).errors).toContainEqual({ status, caption })
})
it('adds the format error to every article card', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
it('adds the format error to every article card except the SEO routes', () => {
for (const endpoint of API_GROUPS[0]!.endpoints.filter(
(item) => !item.path.includes('/seo'),
)) {
expect(endpoint.errors, label(endpoint)).toContainEqual({
status: 400,
caption: 'The format must be one of: markdown, html, json.',
+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 {
+8 -2
View File
@@ -1967,8 +1967,14 @@ export const messages = {
'Lié à votre compte. Les nouveaux articles commencent avec cet auteur.',
"You don't have an author profile yet. Create one to be credited on your own articles.":
'Vous n’avez pas encore de profil d’auteur. Créez-en un pour être crédité sur vos propres articles.',
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.':
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par le propriétaire.',
'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.':
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire et tout le monde peut lire un sitemap ; les modifications se font dans le navigateur par le propriétaire.',
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.':
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau. Le total est dans l’en-tête X-Total-Count.',
'The request cannot be processed due to a mismatch in the format of the body.':
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
site_not_found: 'Site introuvable',
'25, 50, 100 or 500. Default 100.': '25, 50, 100 ou 500. Par défaut 100.',
'List the sites, the default first and then by name, each with its article count.':
'Liste les sites, le site par défaut d’abord puis par nom, chacun avec son nombre d’articles.',
'You must be logged in to access this resource.':
@@ -170,15 +170,15 @@ describe('Default, every card collapsed', () => {
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the 24 cards as 7, 10, 6 and 1', async () => {
it('groups the cards as 9, 10, 6 and 1', async () => {
const wrapper = await mountIntegration()
expect(
['articles', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([7, 10, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('7 routes')
).toEqual([9, 10, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('9 routes')
expect(section(wrapper, 'settings').text()).toContain('1 route')
})