feat: SEO SPA editor integration

This commit is contained in:
Florian Sylvain committed 2026-10-16 03:06:57 +02:00
1 parent eb9876fde3
commit 470584400f
62 files changed
+7471 -58

No files matched your search

@@ -0,0 +1,230 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useArticleDraft } from '@/composables/useArticleDraft'
import { EMPTY_SEO, seoOf, type ArticleSeo } from '@/lib/articleSeo'
import type { Article } from '@/composables/useArticles'
import type { Site } from '@/composables/useSites'
function reply(status: number, body: unknown = null, text = '') {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
text: async () => text,
} as unknown as Response
}
const saved: ArticleSeo = {
...EMPTY_SEO,
slug: 'how-we-price-a-bathroom-renovation',
cover_image_id: 3,
}
function loaded(sent = vi.fn()) {
setActivePinia(createPinia())
global.fetch = sent.mockResolvedValue(reply(200, {})) as unknown as typeof fetch
const draft = useArticleDraft(12)
draft.load('<p>Body</p>', new Date('2026-09-26T11:02:00Z'), undefined, saved)
return { draft, sent }
}
function lastBody(sent: ReturnType<typeof vi.fn>) {
return JSON.parse((sent.mock.calls.at(-1)![1] as RequestInit).body as string)
}
afterEach(() => {
sessionStorage.clear()
vi.restoreAllMocks()
})
describe('The API types carry the SEO fields', () => {
it('reads every SEO field of an article, and falls back to empty values for a list item', () => {
const article: Article = {
id: 12,
title: 'T',
body: '',
is_online: false,
created_at: '',
updated_at: '',
slug: 'a-slug',
excerpt: 'An excerpt',
seo_title: 'Title',
seo_description: 'Description',
cover_image_id: 4,
cover_image_alt: 'Alt',
social_title: 'Social',
social_description: 'Social description',
social_image_id: null,
canonical_url: 'https://renew.example/a',
noindex: true,
}
expect(seoOf(article)).toEqual({
slug: 'a-slug',
excerpt: 'An excerpt',
seo_title: 'Title',
seo_description: 'Description',
cover_image_id: 4,
cover_image_alt: 'Alt',
social_title: 'Social',
social_description: 'Social description',
social_image_id: null,
canonical_url: 'https://renew.example/a',
noindex: true,
})
expect(seoOf({ slug: 'only-a-slug' })).toEqual({ ...EMPTY_SEO, slug: 'only-a-slug' })
})
it('reads the site fields the previews need', () => {
const site: Site = {
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
default_social_image_id: null,
default_robots: 'index',
locale: null,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: 'About Renew',
is_default: true,
article_count: 1,
}
expect([site.site_name, site.title_template, site.default_description]).toEqual([
'Renew',
'%title% | %site%',
'About Renew',
])
})
})
describe('The draft sends only the SEO fields that changed', () => {
it('sends seo_title alone for a meta title edit', async () => {
const { draft, sent } = loaded()
draft.editSeo({ seo_title: 'A new meta title' })
expect(draft.state.value).toBe('dirty')
await draft.save()
expect(lastBody(sent)).toEqual({ seo_title: 'A new meta title' })
})
it('sends null for a removed image and an empty string for a reset slug', async () => {
const { draft, sent } = loaded()
draft.editSeo({ cover_image_id: null, slug: '' })
await draft.save()
expect(lastBody(sent)).toEqual({ cover_image_id: null, slug: '' })
})
it('sends no request when the edited value equals the saved one', async () => {
const { draft, sent } = loaded()
draft.editSeo({ seo_title: '' })
expect(draft.state.value).toBe('clean')
await draft.save()
expect(sent).not.toHaveBeenCalled()
})
it('puts a pending body edit and a SEO edit in the same PATCH', async () => {
const { draft, sent } = loaded()
draft.edit('<p>Edited</p>')
draft.editSeo({ noindex: true })
await draft.save()
expect(sent).toHaveBeenCalledTimes(1)
expect(lastBody(sent)).toEqual({ noindex: true, body: '<p>Edited</p>' })
})
it('adopts the values the API returns, such as a regenerated slug', async () => {
const { draft, sent } = loaded()
sent.mockResolvedValue(reply(200, { slug: 'regenerated-slug' }))
draft.editSeo({ slug: '' })
await draft.save()
expect(draft.seo.value.slug).toBe('regenerated-slug')
expect(draft.savedSeo.value.slug).toBe('regenerated-slug')
})
})
describe('The SEO fields are mirrored in sessionStorage', () => {
it('stores them on a 401 and restores them as an unsaved draft', async () => {
const { draft, sent } = loaded()
sent.mockResolvedValue(reply(401))
draft.editSeo({ seo_title: 'Kept across the login' })
const outcome = await draft.save()
expect(outcome).toBe('unauthorized')
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)).toMatchObject({
seo_title: 'Kept across the login',
})
setActivePinia(createPinia())
const restored = useArticleDraft(12)
restored.load('<p>Body</p>', new Date(), undefined, saved)
expect(restored.state.value).toBe('dirty')
expect(restored.seo.value.seo_title).toBe('Kept across the login')
expect(restored.savedSeo.value.seo_title).toBe('')
})
it('clears the mirror after a successful save', async () => {
const { draft } = loaded()
draft.editSeo({ seo_title: 'Soon saved' })
draft.mirrorToStorage()
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).not.toBeNull()
await draft.save()
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).toBeNull()
})
it('keeps working in memory when sessionStorage throws', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('blocked', 'SecurityError')
})
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blocked', 'SecurityError')
})
const { draft, sent } = loaded()
draft.editSeo({ seo_title: 'In memory' })
draft.mirrorToStorage()
const outcome = await draft.save()
expect(outcome).toBe('saved')
expect(lastBody(sent)).toEqual({ seo_title: 'In memory' })
})
})
describe('The save outcomes for a refused SEO edit', () => {
it('answers slug-taken for a 409 slug_taken and keeps the draft dirty', async () => {
const { draft, sent } = loaded()
sent.mockResolvedValue(reply(409, null, 'slug_taken'))
draft.editSeo({ slug: 'bathroom-costs' })
const outcome = await draft.save()
expect(outcome).toBe('slug-taken')
expect(draft.state.value).toBe('dirty')
expect(draft.savedSeo.value.slug).toBe('how-we-price-a-bathroom-renovation')
expect(draft.seo.value.slug).toBe('bathroom-costs')
})
it('answers invalid-url for a 422 invalid_url and keeps the draft dirty', async () => {
const { draft, sent } = loaded()
sent.mockResolvedValue(reply(422, null, 'invalid_url'))
draft.editSeo({ canonical_url: 'https://' })
const outcome = await draft.save()
expect(outcome).toBe('invalid-url')
expect(draft.state.value).toBe('dirty')
})
})
+98 -10
View File
@@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article, Byline } from '@/composables/useArticles'
import { sameIds, toBylines } from '@/lib/articleMeta'
import { EMPTY_SEO, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
@@ -13,6 +14,8 @@ export type SaveOutcome =
| 'unauthorized'
| 'site-gone'
| 'author-gone'
| 'slug-taken'
| 'invalid-url'
export interface ArticleMeta {
siteId: number | null
@@ -27,6 +30,13 @@ function metaStorageKey(articleId: number) {
return `renewcms:article-draft-meta:${articleId}`
}
function seoStorageKey(articleId: number) {
return `renewcms:article-draft-seo:${articleId}`
}
const SLUG_TAKEN_CODE = 'slug_taken'
const INVALID_URL_CODE = 'invalid_url'
function readStorage(key: string): string | null {
try {
return sessionStorage.getItem(key)
@@ -67,6 +77,18 @@ function readMirroredMeta(articleId: number): ArticleMeta | null {
}
}
function readMirroredSeo(articleId: number, base: ArticleSeo): ArticleSeo | null {
const raw = readStorage(seoStorageKey(articleId))
if (raw === null) {
return null
}
try {
return seoOf(JSON.parse(raw), base)
} catch {
return null
}
}
export function readMirroredDraft(articleId: number): string | null {
return readStorage(draftStorageKey(articleId))
}
@@ -78,6 +100,8 @@ export function useArticleDraft(articleId: number) {
const bodyEdited = ref(false)
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] })
const seo = ref<ArticleSeo>({ ...EMPTY_SEO })
const savedSeo = ref<ArticleSeo>({ ...EMPTY_SEO })
const businessState = ref<ArticleDraftBusinessState>('clean')
const savedAt = ref<Date | null>(null)
const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine)
@@ -112,6 +136,10 @@ export function useArticleDraft(articleId: number) {
return !sameIds(meta.value.authors, savedMeta.value.authors)
}
function seoPatch() {
return seoChanges(seo.value, savedSeo.value)
}
function mirrorToStorage() {
if (bodyEdited.value) {
writeStorage(draftStorageKey(articleId), body.value)
@@ -125,22 +153,35 @@ export function useArticleDraft(articleId: number) {
}),
)
}
if (Object.keys(seoPatch()).length > 0) {
writeStorage(seoStorageKey(articleId), JSON.stringify(seo.value))
}
}
function clearStorage() {
removeStorage(draftStorageKey(articleId))
removeStorage(metaStorageKey(articleId))
removeStorage(seoStorageKey(articleId))
}
function load(initialBody: string, initialSavedAt: Date, initialMeta?: ArticleMeta) {
function load(
initialBody: string,
initialSavedAt: Date,
initialMeta?: ArticleMeta,
initialSeo?: ArticleSeo,
) {
const mirrored = readMirroredDraft(articleId)
const mirroredMeta = readMirroredMeta(articleId)
savedAt.value = initialSavedAt
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
meta.value = mirroredMeta ?? savedMeta.value
savedSeo.value = initialSeo ?? { ...EMPTY_SEO }
const mirroredSeo = readMirroredSeo(articleId, savedSeo.value)
seo.value = mirroredSeo ?? savedSeo.value
bodyEdited.value = mirrored !== null
body.value = mirrored ?? initialBody
businessState.value = mirrored !== null || mirroredMeta !== null ? 'dirty' : 'clean'
const restored = mirrored !== null || mirroredMeta !== null || mirroredSeo !== null
businessState.value = restored ? 'dirty' : 'clean'
}
function edit(newBody: string) {
@@ -154,8 +195,24 @@ export function useArticleDraft(articleId: number) {
businessState.value = 'dirty'
}
function editSeo(next: Partial<ArticleSeo>) {
const merged = { ...seo.value, ...next }
if (Object.keys(seoChanges(merged, seo.value)).length === 0) {
return
}
seo.value = merged
businessState.value = 'dirty'
}
function markDirty() {
if (businessState.value === 'error') {
businessState.value = 'dirty'
}
}
function savePayload() {
return {
...seoPatch(),
...(bodyEdited.value ? { body: body.value } : {}),
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
...(authorsChanged()
@@ -164,6 +221,14 @@ export function useArticleDraft(articleId: number) {
}
}
function rejectedOutcome(err: unknown): SaveOutcome | null {
const code = codeOf(err)
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
return 'slug-taken'
}
return statusOf(err) === 422 && code === INVALID_URL_CODE ? 'invalid-url' : null
}
function goneOutcome(err: unknown): SaveOutcome | null {
if (statusOf(err) !== 404) {
return null
@@ -179,20 +244,28 @@ export function useArticleDraft(articleId: number) {
businessState.value = 'saving'
const payload = savePayload()
try {
if (Object.keys(payload).length > 0) {
await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
const saved =
Object.keys(payload).length > 0
? await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
: undefined
businessState.value = 'saved'
savedAt.value = new Date()
savedMeta.value = meta.value
savedSeo.value = seoOf(saved, seo.value)
seo.value = savedSeo.value
bodyEdited.value = false
clearStorage()
return 'saved'
} catch (err) {
const rejected = rejectedOutcome(err)
if (rejected) {
businessState.value = 'dirty'
return rejected
}
const gone = goneOutcome(err)
if (gone) {
businessState.value = 'error'
@@ -212,5 +285,20 @@ export function useArticleDraft(articleId: number) {
}
}
return { body, meta, state, savedAt, load, edit, editMeta, save }
return {
body,
meta,
seo,
savedSeo,
bodyEdited,
mirrorToStorage,
state,
savedAt,
load,
edit,
editMeta,
editSeo,
markDirty,
save,
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
import type { ArticleSeo } from '@/lib/articleSeo'
import { useApi } from '@/composables/useApi'
import type { Byline } from '@/lib/articleTypes'
import type { ArticleStatus } from '@/lib/articleListQuery'
export type { Byline }
export interface Article {
export interface Article extends Partial<ArticleSeo> {
id: number
title: string
body: string
+34
View File
@@ -0,0 +1,34 @@
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import type { MediaItem, MediaListResponse } from '@/composables/useMedia'
const LOOKUP_QUERY = 'page=1&size=100&sort=newest'
export function useMediaNames() {
const { request } = useApi()
const items = ref<Record<number, MediaItem>>({})
function remember(item: MediaItem) {
items.value = { ...items.value, [item.id]: item }
}
async function resolve(ids: (number | null | undefined)[]) {
const wanted = ids.filter((id): id is number => typeof id === 'number')
if (wanted.every((id) => id in items.value)) {
return
}
try {
const page = await request<MediaListResponse>(`/v1/media/?${LOOKUP_QUERY}`)
const found = (page?.items ?? []).filter((item) => wanted.includes(item.id))
found.forEach(remember)
} catch {
return
}
}
function itemOf(id: number | null | undefined) {
return typeof id === 'number' ? (items.value[id] ?? null) : null
}
return { itemOf, remember, resolve }
}
+42
View File
@@ -0,0 +1,42 @@
import { onBeforeUnmount, ref } from 'vue'
import { useApi } from '@/composables/useApi'
import type { ResolvedSeo } from '@/lib/seoTypes'
export type ResolvedSeoPhase = 'idle' | 'loading' | 'ready' | 'error'
export function useResolvedSeo(articleId: number) {
const { request } = useApi()
const phase = ref<ResolvedSeoPhase>('idle')
const resolved = ref<ResolvedSeo | null>(null)
let controller: AbortController | null = null
let ticket = 0
async function load() {
controller?.abort()
controller = new AbortController()
const current = ++ticket
phase.value = 'loading'
try {
const answer = await request<ResolvedSeo>(`/v1/article/${articleId}/seo`, {
signal: controller.signal,
})
if (current === ticket) {
resolved.value = answer
phase.value = 'ready'
}
} catch {
if (current === ticket) {
phase.value = 'error'
}
}
}
function stop() {
controller?.abort()
ticket += 1
}
onBeforeUnmount(stop)
return { phase, resolved, load, stop }
}
+150
View File
@@ -0,0 +1,150 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { SeoCheckBody, SeoCheckResult } from '@/lib/seoTypes'
export const SEO_CHECK_DEBOUNCE_MS = 500
export type SeoCheckPhase = 'idle' | 'pending' | 'ready' | 'failed'
export function isChecking(phase: SeoCheckPhase) {
return phase === 'pending' || phase === 'idle'
}
export interface SeoCheckOptions {
articleId: number
body: () => SeoCheckBody
onGone: () => void
onUnauthorized?: () => void
}
export function useSeoCheck(options: SeoCheckOptions) {
const { request } = useApi()
const result = ref<SeoCheckResult | null>(null)
const phase = ref<SeoCheckPhase>('idle')
const checkedSlug = ref<string | undefined>(undefined)
let timer: ReturnType<typeof setTimeout> | null = null
let controller: AbortController | null = null
let ticket = 0
let lastPayload: string | null = null
const answers = new Map<string, { result: SeoCheckResult; slug: string | undefined }>()
function clearTimer() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
function schedule() {
clearTimer()
timer = setTimeout(() => {
timer = null
run()
}, SEO_CHECK_DEBOUNCE_MS)
}
async function send(body: SeoCheckBody, payload: string) {
controller?.abort()
controller = new AbortController()
const current = ++ticket
phase.value = 'pending'
try {
const answer = await request<SeoCheckResult>(
`/v1/article/${options.articleId}/seo/check`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
signal: controller.signal,
},
)
if (current !== ticket) {
return
}
result.value = answer
checkedSlug.value = body.slug
phase.value = 'ready'
answers.set(payload, { result: answer, slug: body.slug })
} catch (err) {
if (current !== ticket) {
return
}
if (err instanceof ApiError && err.status === 404) {
options.onGone()
return
}
if (err instanceof ApiError && err.status === 401) {
options.onUnauthorized?.()
return
}
phase.value = 'failed'
}
}
function run(force = false) {
clearTimer()
const body = options.body()
const payload = JSON.stringify(body)
if (!force && payload === lastPayload && phase.value !== 'failed') {
return Promise.resolve()
}
lastPayload = payload
return send(body, payload)
}
function runNow() {
return run(false)
}
function runFresh() {
return run(true)
}
function restore() {
const payload = JSON.stringify(options.body())
const answer = answers.get(payload)
clearTimer()
controller?.abort()
ticket += 1
if (!answer) {
lastPayload = null
phase.value = result.value ? 'ready' : 'idle'
return
}
lastPayload = payload
result.value = answer.result
checkedSlug.value = answer.slug
phase.value = 'ready'
}
function invalidate() {
answers.clear()
}
function stop() {
clearTimer()
controller?.abort()
ticket += 1
}
onBeforeUnmount(stop)
const status = computed(() => result.value?.status ?? null)
const suggestions = computed(() => result.value?.suggestions ?? [])
const slugVerdict = computed(() => result.value?.slug ?? null)
return {
result,
phase,
status,
suggestions,
slugVerdict,
checkedSlug,
schedule,
runNow,
runFresh,
restore,
invalidate,
stop,
}
}
+3
View File
@@ -12,6 +12,9 @@ export interface Site {
default_social_image_id: number | null
default_robots: SiteRobots
locale: string | null
site_name?: string
title_template?: string
default_description?: string
is_default: boolean
article_count: number
}