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

+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { isChecking } from '@/composables/useSeoCheck'
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
import type { SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
function suggestion(code: string, params: Record<string, number> = {}): SeoSuggestion {
return { code: code as SuggestionCode, field: 'body', params }
}
describe('suggestionMessage', () => {
it('uses the same wording for short and long descriptions with the length filled in', () => {
const short = suggestionMessage(suggestion('desc_short', { length: 12 }))
const long = suggestionMessage(suggestion('desc_long', { length: 200 }))
expect(short).toContain('12')
expect(long).toContain('200')
expect(short.replace('12', '#')).toBe(long.replace('200', '#'))
})
it('switches between the singular and plural body image wording', () => {
const one = suggestionMessage(suggestion('body_alt', { count: 1 }))
const many = suggestionMessage(suggestion('body_alt', { count: 3 }))
expect(one).not.toBe(many)
expect(many).toContain('3')
expect(one).not.toContain('1 images')
})
it('falls back to the raw code for an unknown code', () => {
expect(suggestionMessage(suggestion('brand_new'))).toBe('brand_new')
})
})
describe('suggestionLinkLabel', () => {
it('returns an empty label for an unknown field', () => {
expect(suggestionLinkLabel('nope' as never)).toBe('')
})
})
describe('isChecking', () => {
it.each([
['idle', true],
['pending', true],
['ready', false],
['failed', false],
] as const)('%s is %s', (phase, expected) => {
expect(isChecking(phase)).toBe(expected)
})
})
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { EMPTY_SEO } from '@/lib/articleSeo'
import {
applyTitleTemplate,
bodyText,
firstSentence,
previewTitle,
siteDisplayName,
socialTitle,
} from '@/lib/seoPreview'
import type { Site } from '@/composables/useSites'
const site = {
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
default_social_image_id: null,
default_robots: 'index',
locale: null,
site_name: '',
title_template: '%title% | %site%',
is_default: true,
article_count: 1,
} as Site
describe('applyTitleTemplate', () => {
it('replaces every occurrence in a single pass', () => {
expect(applyTitleTemplate('%site%: %title% (%title%)', 'A', 'S')).toBe('S: A (A)')
})
it('does not expand replacement patterns or substitute tokens inside the title', () => {
expect(applyTitleTemplate('%title% | %site%', "Cost $& $' $` %site%", 'Renew')).toBe(
"Cost $& $' $` %site% | Renew",
)
})
it('falls back to the default template and trims', () => {
expect(applyTitleTemplate('', 'A', '')).toBe('A |')
})
})
describe('bodyText', () => {
it('separates block elements with a space', () => {
expect(bodyText('<p>One.</p><p>Two</p>')).toBe('One. Two')
expect(firstSentence('<p>One.</p><p>Two</p>')).toBe('One.')
})
it('skips script and style text', () => {
expect(bodyText('<style>p{}</style><p>Kept</p><script>alert(1)</script>')).toBe('Kept')
})
it('does not run markup', () => {
const img = '<img src=x onerror="document.body.dataset.ran=1">'
bodyText(img)
expect(document.body.dataset.ran).toBeUndefined()
})
})
describe('titles', () => {
const input = { seo: { ...EMPTY_SEO }, title: 'Article', body: '', site }
it('uses the site name, then the site key name, for the template', () => {
expect(siteDisplayName(site)).toBe('Renew')
expect(previewTitle(input)).toBe('Article | Renew')
})
it('falls back to the templated title for the social title', () => {
expect(socialTitle(input)).toBe('Article | Renew')
expect(socialTitle({ ...input, seo: { ...EMPTY_SEO, seo_title: 'Meta' } })).toBe(
'Meta | Renew',
)
expect(socialTitle({ ...input, seo: { ...EMPTY_SEO, social_title: 'Own' } })).toBe('Own')
})
})