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
@@ -76,7 +76,7 @@ function remove() {
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span
|
||||
v-if="caption"
|
||||
class="truncate font-mono text-xs text-highlighted"
|
||||
class="truncate font-mono text-xs/5 text-highlighted"
|
||||
data-testid="media-field-caption"
|
||||
>{{ caption }}</span
|
||||
>
|
||||
|
||||
@@ -30,7 +30,6 @@ import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import {
|
||||
DESCRIPTION_RANGE,
|
||||
SOCIAL_TITLE_RANGE,
|
||||
TITLE_RANGE,
|
||||
hasSocialOverrides,
|
||||
siteDisplayName,
|
||||
@@ -328,7 +327,7 @@ onMounted(() => {
|
||||
names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id])
|
||||
})
|
||||
|
||||
const DISABLED_FIELD = 'disabled:opacity-40'
|
||||
const DISABLED_FIELD = 'disabled:bg-elevated disabled:opacity-100'
|
||||
const fieldUi = { base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}` }
|
||||
const monoFieldUi = {
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`,
|
||||
@@ -553,13 +552,6 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social title')" optional>
|
||||
<template #hint>
|
||||
<SoftCounter
|
||||
:value="charCount(seo.social_title)"
|
||||
:min="SOCIAL_TITLE_RANGE.min"
|
||||
:max="SOCIAL_TITLE_RANGE.max"
|
||||
/>
|
||||
</template>
|
||||
<UInput
|
||||
:model-value="seo.social_title"
|
||||
:placeholder="seo.seo_title || articleTitle"
|
||||
@@ -639,7 +631,7 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
|
||||
"
|
||||
:ui="{
|
||||
root: 'items-start gap-3',
|
||||
base: 'mt-0.5 h-4.5 w-8 data-[disabled]:opacity-40',
|
||||
base: 'mt-0.5 h-4.5 w-8',
|
||||
thumb: 'size-3.5',
|
||||
label: 'font-medium text-highlighted',
|
||||
description: 'text-xs/4.5 text-pretty text-muted',
|
||||
|
||||
@@ -2021,22 +2021,15 @@ export const messages = {
|
||||
Suggestions: 'Suggestions',
|
||||
'The meta title is {0} characters. Search engines may cut it off after about {1}.':
|
||||
'Le titre meta fait {0} caractères. Les moteurs de recherche peuvent le couper après environ {1}.',
|
||||
'The meta title is empty.': 'Le titre meta est vide.',
|
||||
'The description is {0} characters. About 70 to 160 reads best in results.':
|
||||
'La description fait {0} caractères. Entre 70 et 160 environ se lit mieux dans les résultats.',
|
||||
'The cover image has no alt text. Add a short description of it.':
|
||||
'L’image de couverture n’a pas de texte alternatif. Ajoutez-en une courte description.',
|
||||
'This article has no cover image.': 'Cet article n’a pas d’image de couverture.',
|
||||
'The URL slug is {0} characters. Shorter addresses are easier to share.':
|
||||
'Le slug de l’URL fait {0} caractères. Les adresses plus courtes se partagent plus facilement.',
|
||||
'This article has no author.': 'Cet article n’a pas d’auteur.',
|
||||
'The body has no headings. H2 headings help readers scan the article.':
|
||||
'Le corps n’a pas de titres. Les titres H2 aident les lecteurs à parcourir l’article.',
|
||||
'1 image in the body has no alt text.': '1 image du corps n’a pas de texte alternatif.',
|
||||
'{0} images in the body have no alt text.':
|
||||
'{0} images du corps n’ont pas de texte alternatif.',
|
||||
'The body is {0} characters. Longer articles tend to do better in search.':
|
||||
'Le corps fait {0} caractères. Les articles plus longs ont tendance à mieux se classer.',
|
||||
'Go to meta title': 'Aller au titre meta',
|
||||
'Go to meta description': 'Aller à la description meta',
|
||||
'Go to cover image': 'Aller à l’image de couverture',
|
||||
@@ -2132,6 +2125,25 @@ export const messages = {
|
||||
'Les suggestions ne bloquent jamais l’enregistrement ni la publication.',
|
||||
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.":
|
||||
'Décrit l’image pour les personnes qui utilisent un lecteur d’écran, et s’affiche si elle ne charge pas. Laissez vide si l’image est purement décorative.',
|
||||
'No meta title yet. The article title is used instead.':
|
||||
'Pas encore de titre meta. Le titre de l’article est utilisé à la place.',
|
||||
'The description is {0} characters. Search engines may cut it off after about {1}.':
|
||||
'La description fait {0} caractères. Les moteurs de recherche peuvent la couper après environ {1}.',
|
||||
"No cover image. The site's social image is used.":
|
||||
'Pas d’image de couverture. L’image de partage du site est utilisée.',
|
||||
'The slug is {0} characters. Shorter addresses are easier to share.':
|
||||
'Le slug fait {0} caractères. Les adresses plus courtes se partagent plus facilement.',
|
||||
'This article has no authors, so the structured data has no author.':
|
||||
'Cet article n’a pas d’auteur, donc les données structurées n’ont pas d’auteur.',
|
||||
'The article is under 300 words. That is fine for a short note.':
|
||||
'L’article fait moins de 300 mots. C’est correct pour une courte note.',
|
||||
'article title + template': 'titre de l’article + modèle',
|
||||
'first sentence of the body': 'première phrase du corps',
|
||||
excerpt: 'extrait',
|
||||
'site default description': 'description par défaut du site',
|
||||
'cover image': 'image de couverture',
|
||||
'site social image': 'image de partage du site',
|
||||
'site address + slug': 'adresse du site + slug',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -166,12 +166,12 @@ describe('Search and sharing in French', () => {
|
||||
const rows = apiPreviewRows(resolved)
|
||||
|
||||
expect(rows.map((row) => row.chip)).toEqual([
|
||||
'Titre de l’article utilisé',
|
||||
'Extrait utilisé',
|
||||
'Adresse du site et slug utilisés',
|
||||
'titre de l’article + modèle',
|
||||
'extrait',
|
||||
'adresse du site + slug',
|
||||
null,
|
||||
null,
|
||||
'Image de partage du site utilisée',
|
||||
'image de partage du site',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -214,9 +214,9 @@ describe('A late answer never overwrites a newer one', () => {
|
||||
gates[0]!.resolve(checkAnswer([], 'hidden'))
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(sectionBody())).toContain('This article has no cover image.')
|
||||
expect(textOf(sectionBody())).toContain("No cover image. The site's social image is used.")
|
||||
expect(textOf(sectionBody())).not.toContain('Hidden from search engines')
|
||||
expect(textOf(panelRoot()!)).toContain('This article has no cover image.')
|
||||
expect(textOf(panelRoot()!)).toContain("No cover image. The site's social image is used.")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -222,13 +222,13 @@ describe('Formatted', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ key: 'title', source: 'title', chip: 'Using article title' },
|
||||
{ key: 'description', source: 'body', chip: 'Using first sentence of the body' },
|
||||
{ key: 'description', source: 'excerpt', chip: 'Using the excerpt' },
|
||||
{ key: 'description', source: 'site', chip: 'Using the site default description' },
|
||||
{ key: 'image', source: 'cover', chip: 'Using cover image' },
|
||||
{ key: 'image', source: 'site', chip: 'Using site social image' },
|
||||
{ key: 'canonical', source: 'site', chip: 'Using site address and slug' },
|
||||
{ key: 'title', source: 'title', chip: 'article title + template' },
|
||||
{ key: 'description', source: 'body', chip: 'first sentence of the body' },
|
||||
{ key: 'description', source: 'excerpt', chip: 'excerpt' },
|
||||
{ key: 'description', source: 'site', chip: 'site default description' },
|
||||
{ key: 'image', source: 'cover', chip: 'cover image' },
|
||||
{ key: 'image', source: 'site', chip: 'site social image' },
|
||||
{ key: 'canonical', source: 'site', chip: 'site address + slug' },
|
||||
{ key: 'image', source: 'none', chip: 'No image' },
|
||||
])('shows "$chip" when fallbacks.$key is $source', async ({ key, source, chip }) => {
|
||||
await mountWithDialog({
|
||||
|
||||
@@ -427,6 +427,8 @@ describe('Advanced, canonical URL', () => {
|
||||
it.each([
|
||||
{ value: 'renew.example/costs', error: true },
|
||||
{ value: 'http://renew.example/a', error: true },
|
||||
{ value: ' https://renew.example/a', error: true },
|
||||
{ value: 'https://renew.example/a ', error: true },
|
||||
{ value: 'https://renew.example/a', error: false },
|
||||
{ value: '', error: false },
|
||||
])('for "$value" the error is shown: $error', async ({ value, error }) => {
|
||||
|
||||
@@ -149,6 +149,8 @@ describe('Saving state', () => {
|
||||
expect(fieldByLabel(panel, 'Meta description')!.disabled).toBe(true)
|
||||
expect(fieldByLabel(panel, 'Excerpt')!.disabled).toBe(true)
|
||||
expect(textOf(panel)).toContain('Saving…')
|
||||
expect(fieldByLabel(panel, 'Meta title')!.className).toContain('disabled:opacity-100')
|
||||
expect(fieldByLabel(panel, 'Meta title')!.className).not.toContain('opacity-40')
|
||||
expect(
|
||||
textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement),
|
||||
).toContain('Save')
|
||||
@@ -286,6 +288,22 @@ describe('The editor Save button treats SEO edits like any other edit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish with a clean draft', () => {
|
||||
it('only sends the publish request', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
backend.responders['PATCH /v1/article/12/publish'] = () => ({
|
||||
...backend.article,
|
||||
is_online: true,
|
||||
})
|
||||
const before = backend.calls.length
|
||||
|
||||
await click('Publish')
|
||||
|
||||
const patches = backend.calls.slice(before).filter((call) => call.method === 'PATCH')
|
||||
expect(patches.map((call) => call.path)).toEqual(['/v1/article/12/publish'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving with unsaved SEO edits', () => {
|
||||
it('asks Leave without saving? when the draft holds an unsaved SEO value', async () => {
|
||||
const { panel } = await failedPanelSave()
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('The open section: address, suggestions and actions', () => {
|
||||
expect(panel).not.toBeNull()
|
||||
const suggestions = panel.querySelector('[data-testid="seo-panel-suggestions"]')!
|
||||
expect(document.activeElement).toBe(suggestions)
|
||||
expect(textOf(suggestions)).toContain('The body is 40 characters.')
|
||||
expect(textOf(suggestions)).toContain('The article is under 300 words.')
|
||||
})
|
||||
|
||||
it('shows no "+N more" action with three suggestions or fewer', async () => {
|
||||
@@ -263,28 +263,33 @@ describe('The messages follow the params of the response', () => {
|
||||
{},
|
||||
'The body has no headings. H2 headings help readers scan the article.',
|
||||
],
|
||||
['title_empty', 'seo_title', {}, 'The meta title is empty.'],
|
||||
['title_empty', 'seo_title', {}, 'No meta title yet. The article title is used instead.'],
|
||||
[
|
||||
'desc_long',
|
||||
'seo_description',
|
||||
{ length: 170, max: 160 },
|
||||
'The description is 170 characters. About 70 to 160 reads best in results.',
|
||||
'The description is 170 characters. Search engines may cut it off after about 160.',
|
||||
],
|
||||
['cover_none', 'cover_image_id', {}, 'This article has no cover image.'],
|
||||
['cover_none', 'cover_image_id', {}, "No cover image. The site's social image is used."],
|
||||
[
|
||||
'slug_long',
|
||||
'slug',
|
||||
{ length: 80, max: 75 },
|
||||
'The URL slug is 80 characters. Shorter addresses are easier to share.',
|
||||
'The slug is 80 characters. Shorter addresses are easier to share.',
|
||||
],
|
||||
[
|
||||
'no_au',
|
||||
'author_ids',
|
||||
{},
|
||||
'This article has no authors, so the structured data has no author.',
|
||||
],
|
||||
['no_au', 'author_ids', {}, 'This article has no author.'],
|
||||
['body_alt', 'body', { count: 2 }, '2 images in the body have no alt text.'],
|
||||
['body_alt', 'body', { count: 1 }, '1 image in the body has no alt text.'],
|
||||
[
|
||||
'short',
|
||||
'body',
|
||||
{ length: 40, min: 300 },
|
||||
'The body is 40 characters. Longer articles tend to do better in search.',
|
||||
'The article is under 300 words. That is fine for a short note.',
|
||||
],
|
||||
] as const)('renders %s with %j', async (code, field, params, message) => {
|
||||
const backend = seoBackend()
|
||||
|
||||
Reference in new issue
Block a user