feat: SEO SPA Sites, Articles and Integration (4/4)

This commit is contained in:
Florian Sylvain committed 2026-10-18 15:51:28 +02:00
1 parent cb737b7ae3
commit 02fb8751fd
47 files changed
+3218 -227

No files matched your search

+28 -2
View File
@@ -10,6 +10,7 @@ describe('parseArticleQuery', () => {
status: '',
sort: '',
site: '',
seo: '',
})
})
@@ -29,6 +30,7 @@ describe('parseArticleQuery', () => {
status: 'draft',
sort: 'title:asc',
site: '',
seo: '',
})
})
@@ -67,7 +69,7 @@ describe('sort parameter', () => {
})
it('omits the default from the route and treats it as equal to no sort', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, site: '' }
const base = { page: 1, size: 25, q: '', status: '' as const, site: '', seo: '' as const }
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({
sort: 'title:asc',
@@ -98,6 +100,7 @@ describe('toRouteQuery', () => {
status: '',
sort: '',
site: '',
seo: '',
}),
).toEqual({})
})
@@ -111,6 +114,7 @@ describe('toRouteQuery', () => {
status: 'online',
sort: 'title:desc',
site: '',
seo: '',
}),
).toEqual({
page: '2',
@@ -131,6 +135,7 @@ describe('sameArticleQuery', () => {
status: '' as const,
sort: '',
site: '',
seo: '',
}
expect(sameArticleQuery(base, { ...base })).toBe(true)
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
@@ -148,7 +153,7 @@ describe('site parameter', () => {
})
it('omits an empty site and tells two queries apart by their site', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '', seo: '' as const }
expect(toRouteQuery({ ...base, site: '' })).toEqual({})
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: '' })).toBe(false)
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: 'journal' })).toBe(
@@ -156,3 +161,24 @@ describe('site parameter', () => {
)
})
})
describe('seo parameter', () => {
it('reads only the value suggestions and writes it after the site', () => {
const parsed = parseArticleQuery({ site: 'journal', seo: 'suggestions' })
expect(parsed.seo).toBe('suggestions')
expect(Object.keys(toRouteQuery(parsed))).toEqual(['site', 'seo'])
})
it.each(['maybe', '', 'READY', 'ready', 'hidden', 'suggestions,x'])(
'drops the unknown value "%s"',
(seo) => {
expect(parseArticleQuery({ seo }).seo).toBe('')
},
)
it('tells two queries apart by their seo filter', () => {
const base = parseArticleQuery({})
expect(sameArticleQuery(base, { ...base, seo: 'suggestions' })).toBe(false)
expect(sameArticleQuery(base, { ...base })).toBe(true)
})
})
+8
View File
@@ -9,6 +9,8 @@ export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
export const SEO_FILTER = 'suggestions'
const SITE_KEY_MAX = 32
const POSITIVE_INTEGER = /^[1-9]\d*$/
@@ -19,6 +21,7 @@ export interface ArticleListQuery {
status: ArticleStatus | ''
sort: string
site: string
seo: '' | typeof SEO_FILTER
}
function first(value: LocationQuery[string] | undefined) {
@@ -52,6 +55,7 @@ export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
status: status === 'online' || status === 'draft' ? status : '',
sort: validSort(first(query.sort)),
site: first(query.site).slice(0, SITE_KEY_MAX),
seo: first(query.seo) === SEO_FILTER ? SEO_FILTER : '',
}
}
@@ -75,6 +79,9 @@ export function toRouteQuery(query: ArticleListQuery) {
if (query.site) {
route.site = query.site
}
if (query.seo) {
route.seo = query.seo
}
return route
}
@@ -85,6 +92,7 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
left.q === right.q &&
left.status === right.status &&
left.site === right.site &&
left.seo === right.seo &&
effectiveSort(left.sort) === effectiveSort(right.sort)
)
}
+17
View File
@@ -0,0 +1,17 @@
export type SeoChip = { kind: 'suggestions'; count: number } | { kind: 'hidden' }
interface SeoStatusSource {
seo_status?: unknown
seo_suggestions?: unknown
}
export function seoChipOf(article: SeoStatusSource): SeoChip | null {
if (article.seo_status === 'hidden') {
return { kind: 'hidden' }
}
const count = article.seo_suggestions
if (article.seo_status === 'suggestions' && typeof count === 'number' && count > 0) {
return { kind: 'suggestions', count }
}
return null
}
+3
View File
@@ -7,6 +7,7 @@ export const SITE_CODES = {
defaultSite: 'default_site',
invalidUrl: 'invalid_site_url',
invalidLocale: 'invalid_locale',
invalidTitleTemplate: 'invalid_title_template',
} as const
export const AUTHOR_CODES = {
@@ -45,6 +46,7 @@ export type FailureKind =
| 'keyTaken'
| 'invalidSiteUrl'
| 'invalidLocale'
| 'invalidTitleTemplate'
| 'slugTaken'
| 'userLinked'
| 'bioTooLong'
@@ -73,6 +75,7 @@ export function classifyFailure(error: unknown, context: FailureContext): Failur
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
[`400 ${SITE_CODES.invalidTitleTemplate}`]: 'invalidTitleTemplate',
},
author: {
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
+20
View File
@@ -0,0 +1,20 @@
import { t } from '@/i18n'
export const SITE_NAME_MAX = 100
export const TITLE_TEMPLATE_MAX = 200
export const DEFAULT_DESCRIPTION_MAX = 320
export const DEFAULT_TITLE_TEMPLATE = '%title% | %site%'
const TITLE_TOKEN = '%title%'
const SITE_TOKEN = '%site%'
const SAMPLE_TITLE = 'How we price a bathroom renovation'
const SAMPLE_SITE_NAME = 'Renew'
export function templateProblem(template: string) {
return template.includes(TITLE_TOKEN) ? '' : t('The template must contain %title%.')
}
export function exampleTitle(template: string, siteName: string, name: string) {
const site = siteName.trim() || name.trim() || SAMPLE_SITE_NAME
return template.split(TITLE_TOKEN).join(SAMPLE_TITLE).split(SITE_TOKEN).join(site)
}