feat: SEO SPA editor integration
This commit is contained in:
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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user