feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
1 parent
cb737b7ae3
commit
02fb8751fd
47 files changed
+3218
-227
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user