feat: public API, SEO and existing surfaces
This commit is contained in:
1 parent
45fbbc33b1
commit
c390fcfe8d
103 files changed
+4476
-231
No files matched your search
@@ -169,6 +169,22 @@ const ARTICLE_LIST_QUERY: ApiField[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const STORY_QUERY: ApiField[] = [
|
||||
{
|
||||
name: 'lang',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'A language tag such as fr or pt-BR. Default: the site default language.',
|
||||
},
|
||||
{
|
||||
name: 'fallback',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Only the value default: serve the published default-language version when the requested one is not published.',
|
||||
},
|
||||
FORMAT_QUERY,
|
||||
]
|
||||
|
||||
const SITEMAP_QUERY: ApiField[] = [
|
||||
{
|
||||
name: 'page',
|
||||
@@ -340,7 +356,8 @@ const resolvedSeo = () => ({
|
||||
image_alt: 'A tiled bathroom wall',
|
||||
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||
site_name: 'Renew',
|
||||
locale: 'en',
|
||||
locale: 'en_GB',
|
||||
locale_alternates: ['fr_FR'],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -363,7 +380,13 @@ const resolvedSeo = () => ({
|
||||
],
|
||||
published_at: '2026-03-12T09:14:00Z',
|
||||
modified_at: '2026-09-10T07:00:00Z',
|
||||
fallbacks: { title: 'title', description: 'excerpt', image: 'cover', canonical: 'site' },
|
||||
fallbacks: {
|
||||
title: 'title',
|
||||
description: 'excerpt',
|
||||
image: 'cover',
|
||||
canonical: 'pattern',
|
||||
site_text_skipped: false,
|
||||
},
|
||||
json_ld: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
@@ -385,6 +408,16 @@ const resolvedSeo = () => ({
|
||||
'@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||
},
|
||||
},
|
||||
lang: 'en',
|
||||
alternates: [
|
||||
{
|
||||
id: 13,
|
||||
lang: 'fr',
|
||||
story_id: 5,
|
||||
slug: 'comment-nous-chiffrons-une-renovation',
|
||||
url: 'https://blog.renew.example/fr/comment-nous-chiffrons-une-renovation',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const seoCheck = () => ({
|
||||
@@ -396,16 +429,53 @@ const seoCheck = () => ({
|
||||
slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' },
|
||||
})
|
||||
|
||||
const PUBLIC_SAMPLE = {
|
||||
lang: 'fr',
|
||||
slug: 'comment-nous-chiffrons-une-renovation',
|
||||
story: 5,
|
||||
}
|
||||
|
||||
const alternates = () => [
|
||||
{
|
||||
id: 10,
|
||||
lang: 'en',
|
||||
story_id: 5,
|
||||
slug: 'how-we-price-a-bathroom-renovation',
|
||||
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
lang: 'de',
|
||||
story_id: 5,
|
||||
slug: 'so-kalkulieren-wir-eine-badsanierung',
|
||||
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
|
||||
},
|
||||
]
|
||||
|
||||
const frenchVersion = (extra: Record<string, unknown> = {}) => ({
|
||||
...article({
|
||||
id: 12,
|
||||
title: 'Comment nous chiffrons une rénovation',
|
||||
slug: PUBLIC_SAMPLE.slug,
|
||||
lang: 'fr',
|
||||
story_id: 5,
|
||||
}),
|
||||
alternates: alternates(),
|
||||
...extra,
|
||||
})
|
||||
|
||||
const sitemapPage = () => [
|
||||
{
|
||||
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
||||
modified_at: '2026-09-10T07:00:00Z',
|
||||
noindex: false,
|
||||
lang: 'en',
|
||||
},
|
||||
{
|
||||
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
|
||||
modified_at: '2026-09-09T07:00:00Z',
|
||||
noindex: true,
|
||||
lang: 'en',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -758,7 +828,8 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
{
|
||||
key: 'articles',
|
||||
title: 'Articles',
|
||||
description: "Read, write and publish articles. Titles can't be changed after creation.",
|
||||
description:
|
||||
'Read, write and publish articles. A title is edited like any other field and never changes the slug.',
|
||||
endpoints: [
|
||||
endpoint(
|
||||
'art-list',
|
||||
@@ -780,6 +851,38 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
json(article()),
|
||||
{ query: [FORMAT_QUERY] },
|
||||
),
|
||||
endpoint(
|
||||
'art-public-lookup',
|
||||
'GET',
|
||||
'/v1/public/site/{key}/lang/{lang}/article/{slug}',
|
||||
'Exact lookup by site, language and slug. Returns the published version and its alternates, the other published versions of the article in enabled languages. It never falls back to another language, ignores any cookie or key, and never returns a draft.',
|
||||
'Public',
|
||||
[
|
||||
e(400, 'invalid_language'),
|
||||
e(400, FORMAT_INVALID),
|
||||
e(404, 'article_not_found'),
|
||||
e(404, 'site_not_found'),
|
||||
],
|
||||
json(frenchVersion()),
|
||||
{ query: [FORMAT_QUERY] },
|
||||
),
|
||||
endpoint(
|
||||
'art-public-story',
|
||||
'GET',
|
||||
'/v1/public/site/{key}/story/{story_id}',
|
||||
'Resolve an article by its story_id in a language, with an explicit fallback to the default language only. The answer adds requested_lang and fallback to the version and its alternates. Without lang the site default language is requested.',
|
||||
'Public',
|
||||
[
|
||||
e(400, 'invalid_language'),
|
||||
e(400, BODY_MISMATCH),
|
||||
e(400, FORMAT_INVALID),
|
||||
e(400, ID_FORMAT),
|
||||
e(404, 'language_not_available'),
|
||||
e(404, 'site_not_found'),
|
||||
],
|
||||
json(frenchVersion({ requested_lang: 'fr', fallback: false })),
|
||||
{ query: STORY_QUERY },
|
||||
),
|
||||
endpoint(
|
||||
'art-create',
|
||||
'POST',
|
||||
@@ -1016,7 +1119,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'site-sitemap',
|
||||
'GET',
|
||||
'/v1/site/{key}/sitemap',
|
||||
'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.',
|
||||
'List the published URLs of a site, one per version in an enabled language, by id ascending, as an array of { url, modified_at, noindex, lang }. The total is in the X-Total-Count header.',
|
||||
'Public',
|
||||
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
|
||||
json(sitemapPage()),
|
||||
@@ -1407,7 +1510,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'med-use',
|
||||
'GET',
|
||||
'/v1/media/{id}/usage',
|
||||
'List the articles, authors and sites that use an image, with the places where each one uses it.',
|
||||
'List the articles, authors and sites that use an image, with the places where each one uses it. There is one article item per version, with its story_id and lang.',
|
||||
'Verified user',
|
||||
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
|
||||
json([
|
||||
@@ -1417,6 +1520,8 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
title: ARTICLE_TITLE,
|
||||
published: true,
|
||||
fields: ['body', 'cover'],
|
||||
story_id: 5,
|
||||
lang: 'en',
|
||||
},
|
||||
{ type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] },
|
||||
{ type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] },
|
||||
@@ -1983,7 +2088,11 @@ function sampleKey(path: string) {
|
||||
}
|
||||
|
||||
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
|
||||
const keyed = item.path.replace('{key}', sampleKey(item.path))
|
||||
const keyed = item.path
|
||||
.replace('{key}', sampleKey(item.path))
|
||||
.replace('{lang}', PUBLIC_SAMPLE.lang)
|
||||
.replace('{slug}', PUBLIC_SAMPLE.slug)
|
||||
.replace('{story_id}', String(PUBLIC_SAMPLE.story))
|
||||
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user