feat: SEO backend output (2/4)
This commit is contained in:
1 parent
0eb46b0d74
commit
eb9876fde3
32 files changed
+3859
-65
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user