fix: SEO SPA editor last fixes (3/4)
This commit is contained in:
1 parent
470584400f
commit
cb737b7ae3
19 files changed
+279
-110
No files matched your search
+16
-10
@@ -14,7 +14,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
|
||||
And the SPA never re-implements a suggestion rule. It renders the suggestions, the order and the status the check returns. The copy of each code lives in the SPA and has a French entry
|
||||
And the suggestion codes are title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short
|
||||
And a suggestion's field maps to a link label: seo_title "meta title", seo_description "meta description", cover_image_alt and cover_image_id "cover image", slug "URL slug", author_ids "authors", body "body"
|
||||
And the soft ranges of the counters are meta title 30 to 60 and description 70 to 160 (docs/design). They colour a counter and never block
|
||||
And the soft ranges of the counters are meta title 30 to 60 and description 70 to 160 (docs/design), and the social title has no counter. They colour a counter and never block
|
||||
And suggestions never disable Save, "Save and publish" or Publish
|
||||
And the sample article is 12, "How we price a bathroom renovation", online on the site Renew (renew.example, id 1), slug "how-we-price-a-bathroom-renovation", with no SEO field set, and the author "Inès Duarte"
|
||||
And the sample cover media is "north-wall.jpg" (the site social image) and the sample panel values are those of the board frames "Panel: filled in" and "Panel: outside the soft ranges"
|
||||
@@ -103,20 +103,19 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
|
||||
| body | body | focus moves to the article body in the editor |
|
||||
| author_ids | authors | the "Site and authors" section opens and focus moves to its list |
|
||||
|
||||
@not-in-design
|
||||
Scenario Outline: Copy of the codes the design does not draw
|
||||
Scenario Outline: Copy of the other codes, as the board draws it
|
||||
Given the check answers the code "<code>" with params <params>
|
||||
Then the message reads "<message>" and has a French entry
|
||||
|
||||
Examples:
|
||||
| code | params | message |
|
||||
| title_empty | {} | The meta title is empty. |
|
||||
| desc_long | {"length": 170, "max": 160} | The description is 170 characters. About 70 to 160 reads best in results. |
|
||||
| cover_none | {} | This article has no cover image. |
|
||||
| slug_long | {"length": 80, "max": 75} | The URL slug is 80 characters. Shorter addresses are easier to share. |
|
||||
| no_au | {} | This article has no author. |
|
||||
| title_empty | {} | No meta title yet. The article title is used instead. |
|
||||
| desc_long | {"length": 170, "max": 160} | The description is 170 characters. Search engines may cut it off after about 160. |
|
||||
| cover_none | {} | No cover image. The site's social image is used. |
|
||||
| slug_long | {"length": 80, "max": 75} | The slug is 80 characters. Shorter addresses are easier to share. |
|
||||
| no_au | {} | This article has no authors, so the structured data has no author. |
|
||||
| body_alt | {"count": 2} | 2 images in the body have no alt text. |
|
||||
| short | {"length": 40, "min": 300} | The body is 40 characters. Longer articles tend to do better in search. |
|
||||
| short | {"length": 40, "min": 300} | The article is under 300 words. That is fine for a short note. |
|
||||
|
||||
Scenario Outline: The messages the design does draw use the params of the response
|
||||
Given the check answers the code "<code>" with params <params>
|
||||
@@ -316,6 +315,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
|
||||
| renew.example/costs | the field shows "Enter a full address starting with https://" and the field is invalid |
|
||||
| http://renew.example/a | the field shows "Enter a full address starting with https://" |
|
||||
| https://renew.example/a | no error is shown |
|
||||
| " https://renew.example/a" | the field shows "Enter a full address starting with https://" (the value is never trimmed) |
|
||||
| | no error is shown and "Canonical URL: automatic" is shown when collapsed |
|
||||
|
||||
Scenario: Canonical refused by the API
|
||||
@@ -380,7 +380,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
|
||||
|
||||
Scenario: Saving state
|
||||
Given "Save changes" was activated
|
||||
Then while the request runs every field of the panel is disabled at 40%, the status reads "Saving…" and the page behind stays as it was
|
||||
Then while the request runs every field of the panel is disabled with its text colour unchanged, the status reads "Saving…" and the page behind stays as it was
|
||||
|
||||
Scenario: Saved
|
||||
When the PATCH answers 200
|
||||
@@ -408,6 +408,12 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
|
||||
When the user activates Save, presses Ctrl or Cmd S, or activates "Save and publish"
|
||||
Then the PATCH holds seo_title and the publish request follows for "Save and publish"
|
||||
|
||||
Scenario: Save and publish saves a dirty draft first
|
||||
Given the draft is dirty
|
||||
When the user activates "Save and publish"
|
||||
Then the PATCH is sent first and the publish request follows only if it succeeded
|
||||
And with a clean draft only the publish request is sent
|
||||
|
||||
Scenario: Leaving with unsaved SEO edits
|
||||
Given the draft holds an unsaved SEO value
|
||||
When the user navigates away
|
||||
|
||||
@@ -50,7 +50,7 @@ Setups go in `web/visual/states.ts`, one setup per state, with mocked /v1 and th
|
||||
|
||||
Each has a French entry in `web/src/i18n/messages.ts`.
|
||||
|
||||
1. The copy of the codes the design does not draw: title_empty, desc_long, cover_none, slug_long, no_au, body_alt, short.
|
||||
1. The codes now use the board copy: title_empty, desc_long, cover_none, slug_long, no_au, body_alt, short.
|
||||
2. The other fallback chips (excerpt, site default description, no image) and the chips of the dialog.
|
||||
3. The check failure text "Couldn't check this article." with "Try again" (no state in the design).
|
||||
4. The "Go to authors" and "Go to URL slug" labels, and where "Go to body" and "Go to authors" move focus.
|
||||
|
||||
@@ -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()
|
||||
|
||||
+68
-12
@@ -5694,6 +5694,22 @@ async function seoSaveFromPanel(page: Page) {
|
||||
.click()
|
||||
}
|
||||
|
||||
const dismissColumnReason =
|
||||
'The board draws a dismiss column and its footnote in the suggestion list; dismissal is deferred to slice 4'
|
||||
const boardOmitsMobileDialogReason =
|
||||
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
|
||||
|
||||
function withMobileException<T extends VisualState>(state: T, mobile: number, reason: string): T {
|
||||
return {
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile },
|
||||
reason,
|
||||
approvedBy: 'fsylvain',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const editorSeoStates: Record<string, VisualState> = {
|
||||
'Collapsed: ready': seoSidebarState({}, false),
|
||||
'Collapsed: suggestions': seoSidebarState(
|
||||
@@ -5715,12 +5731,23 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
true,
|
||||
),
|
||||
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
|
||||
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
|
||||
'Panel: defaults': withMobileException(
|
||||
seoPanelState({ check: seoCheck(['cover_none']) }),
|
||||
26.5,
|
||||
dismissColumnReason,
|
||||
),
|
||||
'Panel: filled in': seoPanelState({ article: seoFilled }),
|
||||
'Panel: outside the soft ranges': seoPanelState({
|
||||
article: { seo_title: seoLongTitle, seo_description: 'What a bathroom renovation costs.' },
|
||||
'Panel: outside the soft ranges': withMobileException(
|
||||
seoPanelState({
|
||||
article: {
|
||||
seo_title: seoLongTitle,
|
||||
seo_description: 'What a bathroom renovation costs.',
|
||||
},
|
||||
check: seoCheck(['title_long', 'desc_short']),
|
||||
}),
|
||||
15.5,
|
||||
dismissColumnReason,
|
||||
),
|
||||
'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
|
||||
'Panel: slug taken': seoPanelState(
|
||||
{
|
||||
@@ -5738,11 +5765,16 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
await page.getByText('is already used on Renew.').waitFor()
|
||||
},
|
||||
),
|
||||
'Panel: cover without alt': seoPanelState({
|
||||
'Panel: cover without alt': withMobileException(
|
||||
seoPanelState({
|
||||
article: seoCoverNoAlt,
|
||||
check: seoCheck(['cover_alt']),
|
||||
}),
|
||||
'Panel: choose cover': seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
|
||||
17,
|
||||
dismissColumnReason,
|
||||
),
|
||||
'Panel: choose cover': withMobileException(
|
||||
seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Choose image', exact: true })
|
||||
@@ -5752,6 +5784,9 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
29,
|
||||
dismissColumnReason,
|
||||
),
|
||||
'Panel: social sharing open': seoPanelState(
|
||||
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
|
||||
(page) => seoOpenDisclosure(page, 'Social sharing'),
|
||||
@@ -5778,13 +5813,20 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
async () => {},
|
||||
700,
|
||||
),
|
||||
'Panel: mobile search preview': seoPanelState(
|
||||
'Panel: mobile search preview': withMobileException(
|
||||
seoPanelState(
|
||||
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
|
||||
async (page) => {
|
||||
await page.getByRole('dialog').getByRole('tab', { name: 'Mobile', exact: true }).click()
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('tab', { name: 'Mobile', exact: true })
|
||||
.click()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
17.5,
|
||||
dismissColumnReason,
|
||||
),
|
||||
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
|
||||
'Panel: saving': seoPanelState(
|
||||
{ overrides: { 'PATCH /v1/article/12': { status: 200, hang: true } } },
|
||||
@@ -6783,15 +6825,22 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
|
||||
(page) => liftAuthor(page, 'Camille Roche'),
|
||||
),
|
||||
'Create author: dialog': metaState({ withAuthor: true }, (page) =>
|
||||
openCreateAuthorDialog(page, 'Zoe Lambert'),
|
||||
'Create author: dialog': withMobileException(
|
||||
metaState({ withAuthor: true }, (page) => openCreateAuthorDialog(page, 'Zoe Lambert')),
|
||||
55,
|
||||
boardOmitsMobileDialogReason,
|
||||
),
|
||||
'Create author: for me': metaState({}, async (page) => {
|
||||
'Create author: for me': withMobileException(
|
||||
metaState({}, async (page) => {
|
||||
await openCreateAuthorDialog(page, 'Marta Alves')
|
||||
await page.getByRole('checkbox', { name: /This is me/ }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'Create author: slug taken': metaState(
|
||||
50,
|
||||
boardOmitsMobileDialogReason,
|
||||
),
|
||||
'Create author: slug taken': withMobileException(
|
||||
metaState(
|
||||
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
|
||||
async (page) => {
|
||||
await openCreateAuthorDialog(page, 'Zoe Lambert')
|
||||
@@ -6800,7 +6849,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Create author: failed': metaState(
|
||||
40,
|
||||
boardOmitsMobileDialogReason,
|
||||
),
|
||||
'Create author: failed': withMobileException(
|
||||
metaState(
|
||||
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
|
||||
async (page) => {
|
||||
await openCreateAuthorDialog(page, 'Zoe Lambert')
|
||||
@@ -6809,6 +6862,9 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
52.5,
|
||||
boardOmitsMobileDialogReason,
|
||||
),
|
||||
Loading: {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
|
||||
Reference in new issue
Block a user