fix: SEO SPA editor last fixes (3/4)
This commit is contained in:
1 parent
470584400f
commit
cb737b7ae3
19 files changed
+316
-147
No files matched your search
@@ -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,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', () => {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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> = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user