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
@@ -2003,6 +2003,135 @@ export const messages = {
|
||||
'Supprimer un auteur, en déplaçant ses articles avec reassign_to',
|
||||
'Create your own author profile, as any verified user':
|
||||
'Créer votre propre profil d’auteur, en tant qu’utilisateur vérifié',
|
||||
'Search and sharing': 'Recherche et partage',
|
||||
'Hidden from search': 'Masqué des recherches',
|
||||
'1 suggestion': '1 suggestion',
|
||||
'{0} suggestions': '{0} suggestions',
|
||||
Ready: 'Prêt',
|
||||
Address: 'Adresse',
|
||||
'Hidden from search engines. This only sets a flag in the API.':
|
||||
'Masqué des moteurs de recherche. Cela ne fait que positionner un indicateur dans l’API.',
|
||||
'Edit search and sharing': 'Modifier la recherche et le partage',
|
||||
'What the frontend gets': 'Ce que reçoit le frontend',
|
||||
'Checking this article…': 'Vérification de cet article…',
|
||||
"Couldn't check this article.": 'Impossible de vérifier cet article.',
|
||||
'All good': 'Tout est bon',
|
||||
'Nothing to suggest for this article.': 'Rien à suggérer pour cet article.',
|
||||
'+{0} more in Search and sharing': '+{0} de plus dans Recherche et partage',
|
||||
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',
|
||||
'Go to URL slug': 'Aller au slug de l’URL',
|
||||
'Go to authors': 'Aller aux auteurs',
|
||||
'Go to body': 'Aller au corps',
|
||||
'URL slug': 'Slug de l’URL',
|
||||
'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on {0}.':
|
||||
'Généré à partir du titre tant que vous ne le modifiez pas. Lettres minuscules, chiffres et tirets. Unique sur {0}.',
|
||||
'Lowercase letters, numbers and hyphens. Unique on {0}.':
|
||||
'Lettres minuscules, chiffres et tirets. Unique sur {0}.',
|
||||
'Reset to match the title': 'Rétablir selon le titre',
|
||||
'Generated from the title': 'Généré à partir du titre',
|
||||
'The slug "{0}" is already used on {1}.': 'Le slug « {0} » est déjà utilisé sur {1}.',
|
||||
'Meta title': 'Titre meta',
|
||||
'Recommended 30 to 60 characters. This is a guide, not a limit.':
|
||||
'30 à 60 caractères recommandés. C’est un repère, pas une limite.',
|
||||
'Using article title': 'Titre de l’article utilisé',
|
||||
'Meta description': 'Description meta',
|
||||
'Recommended 70 to 160 characters.': '70 à 160 caractères recommandés.',
|
||||
'Using first sentence of the body': 'Première phrase du corps utilisée',
|
||||
'Using the excerpt': 'Extrait utilisé',
|
||||
'Using the site default description': 'Description par défaut du site utilisée',
|
||||
Excerpt: 'Extrait',
|
||||
'A short summary. It is also sent as the article teaser.':
|
||||
'Un court résumé. Il est aussi envoyé comme accroche de l’article.',
|
||||
'Used as the description when there is none, and as the teaser for the frontend.':
|
||||
'Utilisé comme description quand il n’y en a pas, et comme accroche pour le frontend.',
|
||||
'Shorten the excerpt to 300 characters or fewer.':
|
||||
'Raccourcissez l’extrait à 300 caractères ou moins.',
|
||||
'Cover image': 'Image de couverture',
|
||||
'Choose a cover image': 'Choisir une image de couverture',
|
||||
'The cover for this article.': 'La couverture de cet article.',
|
||||
'Using the site social image ({0}).': 'Image de partage du site utilisée ({0}).',
|
||||
'Using the site social image.': 'Image de partage du site utilisée.',
|
||||
'Alt text': 'Texte alternatif',
|
||||
"Describe the image for people who can't see it":
|
||||
'Décrivez l’image pour les personnes qui ne la voient pas',
|
||||
'Needed before the cover counts as ready. Sent as image alt in the API.':
|
||||
'Nécessaire avant que la couverture soit prête. Envoyé comme texte alternatif de l’image dans l’API.',
|
||||
'Social sharing': 'Partage social',
|
||||
Optional: 'Facultatif',
|
||||
'Uses the meta values': 'Utilise les valeurs meta',
|
||||
Customised: 'Personnalisé',
|
||||
'Social title': 'Titre social',
|
||||
'Using meta title': 'Titre meta utilisé',
|
||||
'Social description': 'Description sociale',
|
||||
'Using meta description': 'Description meta utilisée',
|
||||
'Social image': 'Image sociale',
|
||||
'Choose a social image': 'Choisir une image sociale',
|
||||
'The image shown when this article is shared.':
|
||||
'L’image affichée quand cet article est partagé.',
|
||||
'Using the cover image.': 'Image de couverture utilisée.',
|
||||
Advanced: 'Avancé',
|
||||
'Canonical URL': 'URL canonique',
|
||||
'Canonical URL: automatic': 'URL canonique : automatique',
|
||||
'Canonical URL set': 'URL canonique définie',
|
||||
'Using site address and slug': 'Adresse du site et slug utilisés',
|
||||
'Only change this if the same content lives at another address.':
|
||||
'Ne modifiez ceci que si le même contenu existe à une autre adresse.',
|
||||
'Enter a full address starting with https://':
|
||||
'Saisissez une adresse complète commençant par https://',
|
||||
'Hide from search engines': 'Masquer des moteurs de recherche',
|
||||
"Sets noindex in the API. It doesn't change who can read the article.":
|
||||
'Positionne noindex dans l’API. Cela ne change pas qui peut lire l’article.',
|
||||
'Search result': 'Résultat de recherche',
|
||||
Desktop: 'Ordinateur',
|
||||
Mobile: 'Mobile',
|
||||
'Cut off here, the way a search engine would.':
|
||||
'Coupé ici, comme le ferait un moteur de recherche.',
|
||||
'Social card': 'Carte sociale',
|
||||
'Using cover image': 'Image de couverture utilisée',
|
||||
'Using site social image': 'Image de partage du site utilisée',
|
||||
'No image': 'Aucune image',
|
||||
Formatted: 'Formaté',
|
||||
'Raw JSON': 'JSON brut',
|
||||
'Copy JSON': 'Copier le JSON',
|
||||
"The SEO data returned by {0}, read only. RenewCMS doesn't render pages or add meta tags: your frontend does.":
|
||||
'Les données SEO renvoyées par {0}, en lecture seule. RenewCMS n’affiche pas de pages et n’ajoute pas de balises meta : c’est votre frontend qui le fait.',
|
||||
'This article is a draft. The API returns this data only to signed-in callers until it is published.':
|
||||
'Cet article est un brouillon. L’API ne renvoie ces données qu’aux appelants connectés tant qu’il n’est pas publié.',
|
||||
"Couldn't load the SEO data": 'Impossible de charger les données SEO',
|
||||
'{0} failed. Your article is unchanged. Try again in a moment.':
|
||||
'{0} a échoué. Votre article est inchangé. Réessayez dans un instant.',
|
||||
'1 social link': '1 lien social',
|
||||
'{0} social links': '{0} liens sociaux',
|
||||
"Couldn't save search and sharing. Your changes are still here. Try again in a moment.":
|
||||
'Impossible d’enregistrer la recherche et le partage. Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
'Search and sharing saved': 'Recherche et partage enregistrés',
|
||||
'Your search and sharing settings are up to date.':
|
||||
'Vos réglages de recherche et de partage sont à jour.',
|
||||
'Suggestions never block saving or publishing.':
|
||||
'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.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,193 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale, setLocale, t } from './index'
|
||||
import { messages } from './messages'
|
||||
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||
import { apiPreviewRows } from '@/lib/seoApiPreview'
|
||||
import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ApiPreviewDialog.vue',
|
||||
'components/FallbackChip.vue',
|
||||
'components/SearchPreview.vue',
|
||||
'components/SearchSharingSection.vue',
|
||||
'components/SeoDisclosure.vue',
|
||||
'components/SeoPanel.vue',
|
||||
'components/SocialCardPreview.vue',
|
||||
'components/SoftCounter.vue',
|
||||
'components/SuggestionList.vue',
|
||||
'lib/seoApiPreview.ts',
|
||||
'lib/seoCopy.ts',
|
||||
'lib/seoPreview.ts',
|
||||
'views/EditorView.vue',
|
||||
]
|
||||
|
||||
const SAME_IN_FRENCH = new Set([
|
||||
'Articles',
|
||||
'1 suggestion',
|
||||
'{0} suggestions',
|
||||
'Suggestions',
|
||||
'Mobile',
|
||||
])
|
||||
|
||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||
|
||||
function keysIn(source: string) {
|
||||
return [...source.matchAll(LITERAL)].map((match) =>
|
||||
(match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'),
|
||||
)
|
||||
}
|
||||
|
||||
const keys = SOURCES.flatMap((file) =>
|
||||
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||
file,
|
||||
key,
|
||||
})),
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Search and sharing: every string has a French entry', () => {
|
||||
it('finds the strings of the section, the panel, the dialog and the previews', () => {
|
||||
expect(keys.length).toBeGreaterThan(90)
|
||||
})
|
||||
|
||||
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||
'%s: "%s" has its own French entry',
|
||||
(_file, key) => {
|
||||
const french = (messages as Record<string, string>)[key as string]
|
||||
expect(french).toMatch(/\S/)
|
||||
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||
expect(french).not.toBe(key)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Search and sharing in French', () => {
|
||||
const codes: SuggestionCode[] = [
|
||||
'title_long',
|
||||
'title_empty',
|
||||
'desc_short',
|
||||
'desc_long',
|
||||
'cover_alt',
|
||||
'cover_none',
|
||||
'slug_long',
|
||||
'no_au',
|
||||
'no_h',
|
||||
'body_alt',
|
||||
'short',
|
||||
]
|
||||
|
||||
function suggestionOf(code: SuggestionCode): SeoSuggestion {
|
||||
return {
|
||||
code,
|
||||
field: 'body',
|
||||
params: { length: 74, max: 60, min: 70, count: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
it('translates the messages of the 11 suggestion codes', () => {
|
||||
const english = codes.map((code) => suggestionMessage(suggestionOf(code)))
|
||||
setLocale('fr')
|
||||
const french = codes.map((code) => suggestionMessage(suggestionOf(code)))
|
||||
|
||||
french.forEach((message, index) => {
|
||||
expect(message).not.toBe(english[index])
|
||||
expect(message).not.toContain('{0}')
|
||||
})
|
||||
expect(french[0]).toBe(
|
||||
'Le titre meta fait 74 caractères. Les moteurs de recherche peuvent le couper après environ 60.',
|
||||
)
|
||||
})
|
||||
|
||||
it('translates the singular and the plural body_alt message', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(suggestionMessage({ code: 'body_alt', field: 'body', params: { count: 1 } })).toBe(
|
||||
'1 image du corps n’a pas de texte alternatif.',
|
||||
)
|
||||
expect(suggestionMessage({ code: 'body_alt', field: 'body', params: { count: 2 } })).toBe(
|
||||
'2 images du corps n’ont pas de texte alternatif.',
|
||||
)
|
||||
})
|
||||
|
||||
it('translates the Go to labels of every field', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(
|
||||
(
|
||||
[
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
'cover_image_alt',
|
||||
'cover_image_id',
|
||||
'slug',
|
||||
'author_ids',
|
||||
'body',
|
||||
] as const
|
||||
).map(suggestionLinkLabel),
|
||||
).toEqual([
|
||||
'Aller au titre meta',
|
||||
'Aller à la description meta',
|
||||
'Aller à l’image de couverture',
|
||||
'Aller à l’image de couverture',
|
||||
'Aller au slug de l’URL',
|
||||
'Aller aux auteurs',
|
||||
'Aller au corps',
|
||||
])
|
||||
})
|
||||
|
||||
it('translates the chips and the dates of the dialog', () => {
|
||||
setLocale('fr')
|
||||
const resolved = {
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
canonical_url: 'https://renew.example/t',
|
||||
robots: 'index, follow',
|
||||
og: { type: 'article', image: 'u', image_alt: 'a' },
|
||||
twitter: { card: 'summary' },
|
||||
authors: [{ name: 'Inès', url: '', same_as: ['a', 'b'] }],
|
||||
published_at: '2026-03-12T09:14:00Z',
|
||||
modified_at: '2026-10-15T11:02:00Z',
|
||||
fallbacks: { title: 'title', description: 'excerpt', image: 'site', canonical: 'site' },
|
||||
json_ld: { '@type': 'Article' },
|
||||
} as unknown as ResolvedSeo
|
||||
|
||||
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',
|
||||
null,
|
||||
null,
|
||||
'Image de partage du site utilisée',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
])
|
||||
expect(rows.find((row) => row.key === 'authors')!.value).toBe('Inès · 2 liens sociaux')
|
||||
})
|
||||
|
||||
it('translates the section strings', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(t('Search and sharing')).toBe('Recherche et partage')
|
||||
expect(t('Edit search and sharing')).toBe('Modifier la recherche et le partage')
|
||||
expect(t('What the frontend gets')).toBe('Ce que reçoit le frontend')
|
||||
expect(t('Search and sharing saved')).toBe('Recherche et partage enregistrés')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user