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,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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -2,5 +2,6 @@ export interface ImageChoice {
|
||||
title: string
|
||||
description: string
|
||||
hint?: string
|
||||
square?: boolean
|
||||
confirmLabel: string
|
||||
}
|
||||
@@ -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'}`
|
||||
|
||||
@@ -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'
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
Reference in new issue
Block a user