fix: SEO SPA editor last fixes (3/4)

This commit is contained in:
Florian Sylvain committed 2026-10-16 16:31:17 +02:00
1 parent 470584400f
commit cb737b7ae3
19 files changed
+316 -147

No files matched your search

+17
View File
@@ -167,3 +167,20 @@ describe('parseDirectoryQuery', () => {
})
})
})
describe('slugify parity with the API slug generator', () => {
it.each([
['Crème Brûlée à la façon', 'creme-brulee-a-la-facon'],
['Straße œuvre Łódź', 'strasse-oeuvre-lodz'],
['Ångström', 'angstrom'],
['Tiếng Việt', 'ti-ng-vi-t'],
[' --Hello, World!-- ', 'hello-world'],
['', ''],
])('turns %j into %j', (text, expected) => {
expect(slugify(text, 100)).toBe(expected)
})
it('cuts to the limit and drops a trailing dash', () => {
expect(slugify('abcd efgh', 5)).toBe('abcd')
})
})
+8 -5
View File
@@ -8,13 +8,16 @@ function suggestion(code: string, params: Record<string, number> = {}): SeoSugge
}
describe('suggestionMessage', () => {
it('uses the same wording for short and long descriptions with the length filled in', () => {
it('fills the length and the maximum into the short and long description messages', () => {
const short = suggestionMessage(suggestion('desc_short', { length: 12 }))
const long = suggestionMessage(suggestion('desc_long', { length: 200 }))
const long = suggestionMessage(suggestion('desc_long', { length: 200, max: 160 }))
expect(short).toContain('12')
expect(long).toContain('200')
expect(short.replace('12', '#')).toBe(long.replace('200', '#'))
expect(short).toBe(
'The description is 12 characters. About 70 to 160 reads best in results.',
)
expect(long).toBe(
'The description is 200 characters. Search engines may cut it off after about 160.',
)
})
it('switches between the singular and plural body image wording', () => {
+1 -1
View File
@@ -114,5 +114,5 @@ export function isAbsoluteHttpsUrl(value: string) {
}
export function canonicalIsValid(value: string) {
return value.trim() === '' || isAbsoluteHttpsUrl(value.trim())
return value === '' || (value === value.trim() && isAbsoluteHttpsUrl(value))
}
+72 -12
View File
@@ -6,28 +6,88 @@ export const SITE_KEY_MAX = 32
export const SITE_KEY_MIN = 2
export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
const LIGATURES: Record<string, string> = {
const FOLDED_LETTERS: Record<string, string> = {
à: 'a',
á: 'a',
â: 'a',
ã: 'a',
ä: 'a',
å: 'a',
ā: 'a',
ă: 'a',
ą: 'a',
ç: 'c',
ć: 'c',
č: 'c',
ĉ: 'c',
ď: 'd',
è: 'e',
é: 'e',
ê: 'e',
ë: 'e',
ē: 'e',
ė: 'e',
ę: 'e',
ě: 'e',
ğ: 'g',
ì: 'i',
í: 'i',
î: 'i',
ï: 'i',
ī: 'i',
į: 'i',
ı: 'i',
ł: 'l',
ñ: 'n',
ń: 'n',
ň: 'n',
ò: 'o',
ó: 'o',
ô: 'o',
õ: 'o',
ö: 'o',
ø: 'o',
ō: 'o',
ő: 'o',
ř: 'r',
ś: 's',
š: 's',
ş: 's',
ș: 's',
ť: 't',
ț: 't',
ţ: 't',
ù: 'u',
ú: 'u',
û: 'u',
ü: 'u',
ū: 'u',
ů: 'u',
ű: 'u',
ų: 'u',
ý: 'y',
ÿ: 'y',
ź: 'z',
ż: 'z',
ž: 'z',
æ: 'ae',
đ: 'd',
œ: 'oe',
ß: 'ss',
ø: 'o',
ł: 'l',
đ: 'd',
ı: 'i',
ľ: 'l',
}
const COMBINING_MARKS = /\p{M}/gu
const NON_SLUG_RUN = /[^a-z0-9]+/g
const EDGE_DASHES = /^-+|-+$/g
const TRAILING_DASHES = /-+$/
const TITLE_PREFIX = /^Dr\.\s*/
const NUMBERED_SUFFIX = /-(\d+)$/
export function slugify(text: string, max: number) {
const folded = text
.toLowerCase()
.normalize('NFD')
.replace(COMBINING_MARKS, '')
.replace(/[æœßøłđı]/g, (letter) => LIGATURES[letter] ?? letter)
return folded.replace(NON_SLUG_RUN, '-').slice(0, max).replace(EDGE_DASHES, '')
const folded = [...text.toLowerCase()]
.map((letter) => FOLDED_LETTERS[letter] ?? letter)
.join('')
const generated = folded.replace(NON_SLUG_RUN, '-').replace(EDGE_DASHES, '')
return generated.slice(0, max).replace(TRAILING_DASHES, '')
}
export function suggestFreeSlug(
+19 -19
View File
@@ -1,5 +1,4 @@
import { t, locale } from '@/i18n'
import { chipLabel, descriptionChip, type ChipKey } from '@/lib/seoCopy'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import type { ResolvedSeo } from '@/lib/seoTypes'
@@ -40,22 +39,23 @@ export function formatApiDate(iso: string | null) {
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${clock}`
}
function titleChip(source: string) {
return source === 'title' ? chipLabel('article_title') : null
const FALLBACK_CHIPS: Record<string, Record<string, () => string>> = {
title: { title: () => t('article title + template') },
description: {
body: () => t('first sentence of the body'),
excerpt: () => t('excerpt'),
site: () => t('site default description'),
},
image: {
cover: () => t('cover image'),
site: () => t('site social image'),
none: () => t('No image'),
},
canonical: { site: () => t('site address + slug') },
}
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 fallbackChip(field: string, source: string) {
return FALLBACK_CHIPS[field]?.[source]?.() ?? null
}
function authorLine(author: ResolvedSeo['authors'][number]) {
@@ -77,20 +77,20 @@ function jsonLdSummary(jsonLd: Record<string, unknown>) {
export function apiPreviewRows(resolved: ResolvedSeo): ApiPreviewRow[] {
const { fallbacks } = resolved
return [
{ key: 'title', value: resolved.title, chip: titleChip(fallbacks.title) },
{ key: 'title', value: resolved.title, chip: fallbackChip('title', fallbacks.title) },
{
key: 'description',
value: resolved.description,
chip: descriptionChip(fallbacks.description),
chip: fallbackChip('description', fallbacks.description),
},
{
key: 'canonical_url',
value: resolved.canonical_url,
chip: canonicalChip(fallbacks.canonical),
chip: fallbackChip('canonical', 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', value: resolved.og.image, chip: fallbackChip('image', fallbacks.image) },
{ key: 'og.image_alt', value: resolved.og.image_alt, chip: null },
{ key: 'twitter.card', value: resolved.twitter.card, chip: null },
{
+10 -11
View File
@@ -14,25 +14,24 @@ const MESSAGES: Record<SuggestionCode, (params: Record<string, number>) => strin
params.length,
params.max,
]),
title_empty: () => t('The meta title is empty.'),
title_empty: () => t('No meta title yet. The article title is used instead.'),
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.', [
desc_long: (params) =>
t('The description is {0} characters. Search engines may cut it off after about {1}.', [
params.length,
params.max,
]),
no_au: () => t('This article has no author.'),
cover_alt: () => t('The cover image has no alt text. Add a short description of it.'),
cover_none: () => t("No cover image. The site's social image is used."),
slug_long: (params) =>
t('The slug is {0} characters. Shorter addresses are easier to share.', [params.length]),
no_au: () => t('This article has no authors, so the structured data 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,
]),
short: () => t('The article is under 300 words. That is fine for a short note.'),
}
const LINK_LABELS: Record<SuggestionField, () => string> = {
-1
View File
@@ -11,7 +11,6 @@ export const PREVIEW_LIMITS: Record<PreviewDevice, { title: number; description:
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