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')
})
})
+118
View File
@@ -0,0 +1,118 @@
export const SEO_KEYS = [
'slug',
'excerpt',
'seo_title',
'seo_description',
'cover_image_id',
'cover_image_alt',
'social_title',
'social_description',
'social_image_id',
'canonical_url',
'noindex',
] as const
export type SeoKey = (typeof SEO_KEYS)[number]
export interface ArticleSeo {
slug: string
excerpt: string
seo_title: string
seo_description: string
cover_image_id: number | null
cover_image_alt: string
social_title: string
social_description: string
social_image_id: number | null
canonical_url: string
noindex: boolean
}
export type SeoPatch = Partial<ArticleSeo>
export const EMPTY_SEO: ArticleSeo = {
slug: '',
excerpt: '',
seo_title: '',
seo_description: '',
cover_image_id: null,
cover_image_alt: '',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
}
export const SEO_LIMITS = {
slug: 100,
excerpt: 300,
seo_title: 120,
seo_description: 320,
cover_image_alt: 200,
social_title: 120,
social_description: 320,
canonical_url: 2048,
}
const NON_SLUG_RUN = /[^a-z0-9]+/g
const EDGE_DASHES = /^-+|-+$/g
const IMAGE_KEYS: SeoKey[] = ['cover_image_id', 'social_image_id']
function hasExpectedType(key: SeoKey, value: unknown) {
if (IMAGE_KEYS.includes(key)) {
return value === null || typeof value === 'number'
}
return typeof value === (key === 'noindex' ? 'boolean' : 'string')
}
export function seoOf(source: object | null | undefined, base: ArticleSeo = EMPTY_SEO): ArticleSeo {
const known = (source ?? {}) as Record<string, unknown>
const next: Record<string, unknown> = { ...base }
for (const key of SEO_KEYS) {
if (key in known && hasExpectedType(key, known[key])) {
next[key] = known[key]
}
}
return next as unknown as ArticleSeo
}
export function seoChanges(current: ArticleSeo, saved: ArticleSeo): SeoPatch {
const changes: Record<string, unknown> = {}
for (const key of SEO_KEYS) {
if (current[key] !== saved[key]) {
changes[key] = current[key]
}
}
return changes as SeoPatch
}
export function sameSeo(left: ArticleSeo, right: ArticleSeo) {
return Object.keys(seoChanges(left, right)).length === 0
}
export function charCount(text: string) {
return [...text].length
}
export function normalizeSlug(raw: string) {
return raw.toLowerCase().replace(NON_SLUG_RUN, '-').replace(EDGE_DASHES, '')
}
export function typedSlug(raw: string) {
return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-')
}
export function isAbsoluteHttpsUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'https:' && url.hostname !== ''
} catch {
return false
}
}
export function canonicalIsValid(value: string) {
return value.trim() === '' || isAbsoluteHttpsUrl(value.trim())
}
+20
View File
@@ -0,0 +1,20 @@
export function booleanMemory(key: string) {
function read() {
try {
const raw = localStorage.getItem(key)
return raw === 'true' ? true : raw === 'false' ? false : null
} catch {
return null
}
}
function store(open: boolean) {
try {
localStorage.setItem(key, String(open))
} catch {
return
}
}
return { read, store }
}
+1
View File
@@ -2,5 +2,6 @@ export interface ImageChoice {
title: string
description: string
hint?: string
square?: boolean
confirmLabel: string
}
+5 -15
View File
@@ -1,22 +1,12 @@
import { booleanMemory } from '@/lib/booleanMemory'
export const META_SECTION_OPEN_KEY = 'renewcms-meta-section-open'
const HINT_DISMISSED_PREFIX = 'renewcms-author-hint-dismissed'
export function readMetaSectionOpen() {
try {
const raw = localStorage.getItem(META_SECTION_OPEN_KEY)
return raw === 'true' ? true : raw === 'false' ? false : null
} catch {
return null
}
}
const memory = booleanMemory(META_SECTION_OPEN_KEY)
export function storeMetaSectionOpen(open: boolean) {
try {
localStorage.setItem(META_SECTION_OPEN_KEY, String(open))
} catch {
return
}
}
export const readMetaSectionOpen = memory.read
export const storeMetaSectionOpen = memory.store
export function hintDismissedKey(userId: number | undefined) {
return `${HINT_DISMISSED_PREFIX}:${userId ?? 'anonymous'}`
+3
View File
@@ -13,3 +13,6 @@ export function segmentClass(selected: boolean, block = false) {
}
export const SEGMENT_GROUP = 'flex shrink-0 border border-accented'
export const SEGMENT_TAB_ACTIVE =
'data-[state=active]:bg-secondary-200 data-[state=active]:font-medium data-[state=active]:text-primary-900 dark:data-[state=active]:bg-secondary-300'
+105
View File
@@ -0,0 +1,105 @@
import { t, locale } from '@/i18n'
import { chipLabel, descriptionChip, type ChipKey } from '@/lib/seoCopy'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import type { ResolvedSeo } from '@/lib/seoTypes'
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
const EMPTY_VALUE = '–'
export interface ApiPreviewRow {
key: string
value: string
chip: string | null
}
function pad(value: number) {
return value.toString().padStart(2, '0')
}
export function formatApiDate(iso: string | null) {
if (!iso) {
return EMPTY_VALUE
}
const date = new Date(iso)
const clock = `${pad(date.getHours())}:${pad(date.getMinutes())}`
return locale.value === 'fr'
? `${formatAbsoluteDateFromIso(iso)}, ${clock}`
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${clock}`
}
function titleChip(source: string) {
return source === 'title' ? chipLabel('article_title') : null
}
function imageChip(source: string) {
const keys: Record<string, ChipKey> = {
cover: 'cover_image',
site: 'site_image',
none: 'no_image',
}
const key = keys[source]
return key ? chipLabel(key) : null
}
function canonicalChip(source: string) {
return source === 'site' ? chipLabel('canonical') : null
}
function authorLine(author: ResolvedSeo['authors'][number]) {
const links = author.same_as.length
return [
author.name,
author.url ? author.url.replace(/^https?:\/\//, '') : '',
links === 0 ? '' : links === 1 ? t('1 social link') : t('{0} social links', [links]),
]
.filter(Boolean)
.join(' · ')
}
function jsonLdSummary(jsonLd: Record<string, unknown>) {
const fields = Object.keys(jsonLd).filter((key) => !key.startsWith('@'))
return [String(jsonLd['@type'] ?? ''), fields.join(', ')].filter(Boolean).join(' · ')
}
export function apiPreviewRows(resolved: ResolvedSeo): ApiPreviewRow[] {
const { fallbacks } = resolved
return [
{ key: 'title', value: resolved.title, chip: titleChip(fallbacks.title) },
{
key: 'description',
value: resolved.description,
chip: descriptionChip(fallbacks.description),
},
{
key: 'canonical_url',
value: resolved.canonical_url,
chip: canonicalChip(fallbacks.canonical),
},
{ key: 'robots', value: resolved.robots, chip: null },
{ key: 'og.type', value: resolved.og.type, chip: null },
{ key: 'og.image', value: resolved.og.image, chip: imageChip(fallbacks.image) },
{ key: 'og.image_alt', value: resolved.og.image_alt, chip: null },
{ key: 'twitter.card', value: resolved.twitter.card, chip: null },
{
key: 'authors',
value: resolved.authors.map(authorLine).join('; ') || EMPTY_VALUE,
chip: null,
},
{ key: 'published_at', value: formatApiDate(resolved.published_at), chip: null },
{ key: 'modified_at', value: formatApiDate(resolved.modified_at), chip: null },
{ key: 'json_ld', value: jsonLdSummary(resolved.json_ld), chip: null },
]
}
+85
View File
@@ -0,0 +1,85 @@
import { t } from '@/i18n'
import type { DescriptionSource } from '@/lib/seoPreview'
import type { SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
function descriptionLength(params: Record<string, number>) {
return t('The description is {0} characters. About 70 to 160 reads best in results.', [
params.length,
])
}
const MESSAGES: Record<SuggestionCode, (params: Record<string, number>) => string> = {
title_long: (params) =>
t('The meta title is {0} characters. Search engines may cut it off after about {1}.', [
params.length,
params.max,
]),
title_empty: () => t('The meta title is empty.'),
desc_short: descriptionLength,
desc_long: descriptionLength,
cover_alt: () => t('The cover image has no alt text. Add a short description of it.'),
cover_none: () => t('This article has no cover image.'),
slug_long: (params) =>
t('The URL slug is {0} characters. Shorter addresses are easier to share.', [
params.length,
]),
no_au: () => t('This article has no author.'),
no_h: () => t('The body has no headings. H2 headings help readers scan the article.'),
body_alt: (params) =>
params.count === 1
? t('1 image in the body has no alt text.')
: t('{0} images in the body have no alt text.', [params.count]),
short: (params) =>
t('The body is {0} characters. Longer articles tend to do better in search.', [
params.length,
]),
}
const LINK_LABELS: Record<SuggestionField, () => string> = {
seo_title: () => t('Go to meta title'),
seo_description: () => t('Go to meta description'),
cover_image_alt: () => t('Go to cover image'),
cover_image_id: () => t('Go to cover image'),
slug: () => t('Go to URL slug'),
author_ids: () => t('Go to authors'),
body: () => t('Go to body'),
}
export function suggestionMessage(suggestion: SeoSuggestion) {
const message = MESSAGES[suggestion.code]
return message ? message(suggestion.params ?? {}) : suggestion.code
}
export function suggestionLinkLabel(field: SuggestionField) {
const label = LINK_LABELS[field]
return label ? label() : ''
}
const CHIPS = {
article_title: () => t('Using article title'),
body: () => t('Using first sentence of the body'),
excerpt: () => t('Using the excerpt'),
site_description: () => t('Using the site default description'),
cover_image: () => t('Using cover image'),
site_image: () => t('Using site social image'),
no_image: () => t('No image'),
canonical: () => t('Using site address and slug'),
meta_title: () => t('Using meta title'),
meta_description: () => t('Using meta description'),
}
export type ChipKey = keyof typeof CHIPS
export function chipLabel(key: ChipKey) {
return CHIPS[key]()
}
export function descriptionChip(source: DescriptionSource | string) {
const keys: Record<string, ChipKey> = {
excerpt: 'excerpt',
body: 'body',
site: 'site_description',
}
const key = keys[source]
return key ? chipLabel(key) : null
}
+128
View File
@@ -0,0 +1,128 @@
import { charCount } from '@/lib/articleSeo'
import type { ArticleSeo } from '@/lib/articleSeo'
import type { Site } from '@/composables/useSites'
export type PreviewDevice = 'desktop' | 'mobile'
export const PREVIEW_LIMITS: Record<PreviewDevice, { title: number; description: number }> = {
desktop: { title: 60, description: 160 },
mobile: { title: 70, description: 120 },
}
export const TITLE_RANGE = { min: 30, max: 60 }
export const DESCRIPTION_RANGE = { min: 70, max: 160 }
export const SOCIAL_TITLE_RANGE = { min: 0, max: 90 }
const DEFAULT_TEMPLATE = '%title% | %site%'
const SENTENCE_END = /^(.+?[.!?])(\s|$)/s
export type TitleSource = 'seo_title' | 'title'
export type DescriptionSource = 'seo_description' | 'excerpt' | 'body' | 'site' | 'none'
export function truncateText(text: string, max: number) {
const characters = [...text]
return characters.length > max
? `${characters
.slice(0, max - 1)
.join('')
.trimEnd()}…`
: text
}
const BLOCK_BOUNDARY = /<\/?(p|div|h[1-6]|li|ul|ol|blockquote|pre|tr|br|hr)\b[^>]*>/gi
const SKIPPED_ELEMENTS = 'script, style'
export function bodyText(html: string) {
const parsed = new DOMParser().parseFromString(
html.replace(BLOCK_BOUNDARY, ' $& '),
'text/html',
)
parsed.querySelectorAll(SKIPPED_ELEMENTS).forEach((element) => element.remove())
return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function firstSentence(html: string) {
const text = bodyText(html)
const match = SENTENCE_END.exec(text)
return match ? match[1]! : text
}
const TEMPLATE_TOKEN = /%title%|%site%/g
export function applyTitleTemplate(template: string, title: string, siteName: string) {
return (template || DEFAULT_TEMPLATE)
.replace(TEMPLATE_TOKEN, (token) => (token === '%title%' ? title : siteName))
.trim()
}
export function siteDisplayName(site: Site | undefined) {
return site?.site_name || site?.name || ''
}
export interface SeoPreviewInput {
seo: ArticleSeo
title: string
body: string
site: Site | undefined
}
export function previewTitleSource(input: SeoPreviewInput): TitleSource {
return input.seo.seo_title.trim() ? 'seo_title' : 'title'
}
export function previewTitle(input: SeoPreviewInput) {
const base = input.seo.seo_title.trim() || input.title
return applyTitleTemplate(input.site?.title_template ?? '', base, siteDisplayName(input.site))
}
export function previewDescriptionSource(input: SeoPreviewInput): DescriptionSource {
if (input.seo.seo_description.trim()) {
return 'seo_description'
}
if (input.seo.excerpt.trim()) {
return 'excerpt'
}
if (firstSentence(input.body)) {
return 'body'
}
return input.site?.default_description ? 'site' : 'none'
}
export function previewDescription(input: SeoPreviewInput) {
const sources: Record<DescriptionSource, string> = {
seo_description: input.seo.seo_description.trim(),
excerpt: input.seo.excerpt.trim(),
body: firstSentence(input.body),
site: input.site?.default_description ?? '',
none: '',
}
return sources[previewDescriptionSource(input)]
}
export function titleIsCut(title: string, device: PreviewDevice) {
return charCount(title) > PREVIEW_LIMITS[device].title
}
export function socialTitle(input: SeoPreviewInput) {
return input.seo.social_title.trim() || previewTitle(input)
}
export function socialDescription(input: SeoPreviewInput) {
return input.seo.social_description.trim() || previewDescription(input)
}
export function hasSocialOverrides(seo: ArticleSeo) {
return Boolean(seo.social_title || seo.social_description || seo.social_image_id !== null)
}
export type SocialImageSource = 'social' | 'cover' | 'site' | 'none'
export function socialImageSource(seo: ArticleSeo, siteHasImage: boolean): SocialImageSource {
if (seo.social_image_id !== null) {
return 'social'
}
if (seo.cover_image_id !== null) {
return 'cover'
}
return siteHasImage ? 'site' : 'none'
}
+8
View File
@@ -0,0 +1,8 @@
import { booleanMemory } from '@/lib/booleanMemory'
export const SEO_SECTION_OPEN_KEY = 'renewcms-seo-section-open'
const memory = booleanMemory(SEO_SECTION_OPEN_KEY)
export const readSeoSectionOpen = memory.read
export const storeSeoSectionOpen = memory.store
+71
View File
@@ -0,0 +1,71 @@
import type { SeoPatch } from '@/lib/articleSeo'
export type SeoCheckStatus = 'ready' | 'suggestions' | 'hidden'
export type SuggestionCode =
| 'title_long'
| 'title_empty'
| 'desc_short'
| 'desc_long'
| 'cover_alt'
| 'cover_none'
| 'slug_long'
| 'no_au'
| 'no_h'
| 'body_alt'
| 'short'
export type SuggestionField =
| 'seo_title'
| 'seo_description'
| 'cover_image_alt'
| 'cover_image_id'
| 'slug'
| 'author_ids'
| 'body'
export interface SeoSuggestion {
code: SuggestionCode
field: SuggestionField
params: Record<string, number>
}
export interface SeoCheckResult {
status: SeoCheckStatus
suggestions: SeoSuggestion[]
slug: { available: boolean; suggestion: string | null }
}
export type SeoCheckBody = SeoPatch & { body?: string }
export type SeoFocus = SuggestionField | 'suggestions'
export interface ResolvedSeo {
title: string
description: string
canonical_url: string
robots: string
og: {
type: string
title: string
description: string
image: string
image_alt: string
url: string
site_name: string
locale: string
}
twitter: {
card: string
title: string
description: string
image: string
image_alt: string
}
image: { url: string; alt: string; source: string }
authors: { name: string; url: string; same_as: string[] }[]
published_at: string | null
modified_at: string
fallbacks: { title: string; description: string; image: string; canonical: string }
json_ld: Record<string, unknown>
}