feat: public API, SEO and existing surfaces

This commit is contained in:
Florian Sylvain committed 2026-11-03 03:58:10 +01:00
1 parent 45fbbc33b1
commit c390fcfe8d
103 files changed
+4476 -231

No files matched your search

+20 -8
View File
@@ -60,12 +60,19 @@ function formatDetailDate(iso: string, relative = false) {
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true))
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const carriesLanguage = computed(() => props.multilingual && Boolean(props.article.lang))
const publicUrl = computed(() =>
carriesLanguage.value
? `/v1/article/${props.article.id}?lang=${props.article.lang}`
: `/v1/article/${props.article.id}`,
)
const imagesCount = computed(() => bodyImageCount(props.article.body))
const apiNote = computed(() =>
props.article.is_online
? t('Live. Your website can read this article.')
: t('Returns 404 until the article is online.'),
carriesLanguage.value
? t('Each language version has its own address. The URL carries the language.')
: props.article.is_online
? t('Live. Your website can read this article.')
: t('Returns 404 until the article is online.'),
)
async function copyUrl() {
@@ -77,12 +84,14 @@ async function copyUrl() {
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Link copied')
return carriesLanguage.value ? t('API URL copied') : t('Link copied')
},
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
props.article.title,
])
return carriesLanguage.value
? publicUrl.value
: t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
props.article.title,
])
},
}))
emit('copy-url')
@@ -198,6 +207,9 @@ async function copyUrl() {
@click="copyUrl"
/>
</div>
<p v-if="carriesLanguage" class="text-xs/[18px] text-muted">
{{ apiNote }}
</p>
</div>
</div>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, ref } from 'vue'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
import LocaleInput from '@/components/localization/LocaleInput.vue'
import { SEO_LIMITS, normalizeSlug } from '@/lib/articleSeo'
const props = defineProps<{
language: string
slug: string
published: boolean
pending: boolean
error: string
}>()
const emit = defineEmits<{ close: []; confirm: [slug: string] }>()
const value = ref(props.slug)
const field = ref<HTMLElement | null>(null)
const unchanged = computed(() => normalizeSlug(value.value) === props.slug)
onMounted(() => {
field.value?.querySelector('input')?.focus()
})
function confirm() {
emit('confirm', normalizeSlug(value.value))
}
</script>
<template>
<LocaleDialog
:title="t('Edit slug for the {0} version', [language])"
:description="t('The address changes for {0} only.', [language])"
:confirm-label="t('Change address')"
:confirm-disabled="unchanged || value.trim() === ''"
:pending="pending"
@close="emit('close')"
@confirm="confirm"
>
<div ref="field" class="flex flex-col gap-1.5">
<label for="edit-slug-input" class="font-medium text-highlighted">{{
t('Address (slug)')
}}</label>
<LocaleInput
id="edit-slug-input"
v-model="value"
mono
:invalid="error !== ''"
:maxlength="SEO_LIMITS.slug"
:aria-label="t('Address (slug)')"
/>
<p v-if="error" role="alert" class="flex gap-1.5 text-xs/4.5 text-error">
{{ error }}
</p>
</div>
<LocaleAlert
v-if="published"
tone="warning"
icon="i-lucide-triangle-alert"
:title="t('This version is published')"
:description="
t('Links to the old address will stop working. RenewCMS does not create redirects.')
"
/>
<p class="text-xs/4.5 text-muted">{{ t('Editing the title never changes the slug.') }}</p>
</LocaleDialog>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { SECTION_TITLE } from '@/lib/apiReference'
import { PUBLIC_LOOKUP_ROUTE, PUBLIC_STORY_ROUTE } from '@/lib/publicArticleRoutes'
const CARD_CLASS = 'flex flex-col gap-3 border border-default bg-default p-4'
const TITLE_CLASS = 'font-semibold text-highlighted'
const ROUTE_CLASS = 'font-mono text-sm break-words text-highlighted'
const SAMPLE_CLASS = 'font-mono text-xs break-words text-muted'
const LOOKUP_SAMPLE =
'{ "id": 12, "lang": "fr", "story_id": 5, "slug": "…", "alternates": [{ "id": 10, "lang": "en", "story_id": 5, "slug": "…", "url": "…" }] }'
</script>
<template>
<section
data-anchor="languages"
aria-labelledby="h-languages"
class="flex scroll-mt-20 flex-col gap-3"
data-testid="integration-languages"
>
<div class="flex flex-col gap-1">
<h2 id="h-languages" :class="SECTION_TITLE">{{ t('Languages') }}</h2>
<p class="text-pretty text-toned">
{{
t(
'Read one published language of an article, or resolve an article in a language. Both requests are public.',
)
}}
</p>
</div>
<div :class="CARD_CLASS" data-testid="integration-lookup">
<span :class="TITLE_CLASS">{{ t('1. Exact lookup: site + language + slug') }}</span>
<span :class="ROUTE_CLASS">{{ PUBLIC_LOOKUP_ROUTE }}</span>
<span class="text-pretty text-toned">{{
t(
'Matches the published version with that slug in that language, or returns article_not_found. It never falls back, because a slug belongs to one language.',
)
}}</span>
<span :class="SAMPLE_CLASS">{{ LOOKUP_SAMPLE }}</span>
</div>
<div :class="CARD_CLASS" data-testid="integration-story">
<span :class="TITLE_CLASS">{{ t('2. Resolve by article group, with fallback') }}</span>
<span :class="ROUTE_CLASS">{{ PUBLIC_STORY_ROUTE }}</span>
<span class="text-pretty text-toned">{{
t(
'Use story_id from any response or from alternates[]. French published: lang "fr". French missing or unpublished with fallback=default: the published default, with "lang": "en", "requested_lang": "fr", "fallback": true. Without fallback=default, a missing French is language_not_available. No other language is tried, and drafts are never returned.',
)
}}</span>
</div>
<div :class="CARD_CLASS" data-testid="integration-errors">
<span :class="TITLE_CLASS">{{ t('3. Errors') }}</span>
<span :class="ROUTE_CLASS"
>article_not_found · language_not_available · site_not_found ·
invalid_language</span
>
<span class="text-pretty text-toned">{{
t(
'article_not_found: no published version has that slug in that language. language_not_available: the requested language and the allowed fallback are both unavailable. site_not_found: no site has that key. invalid_language: the language is not a language tag.',
)
}}</span>
</div>
<div :class="CARD_CLASS" data-testid="integration-unchanged">
<span :class="TITLE_CLASS">{{ t('Existing, unchanged') }}</span>
<span :class="ROUTE_CLASS">GET /v1/article/{id}</span>
<span :class="ROUTE_CLASS">PATCH /v1/article/{id}/publish</span>
<span :class="ROUTE_CLASS">Editor: /article/{id}/edit</span>
<span class="text-pretty text-toned">{{
t('Each article ID is one language version. Article-ID links keep working.')
}}</span>
</div>
</section>
</template>
+16 -7
View File
@@ -14,7 +14,7 @@ import {
joinNames,
pluralArticles,
} from '@/lib/mediaFormat'
import { usageSplitOf } from '@/lib/mediaUsage'
import { usageLanguages, usageSplitOf } from '@/lib/mediaUsage'
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { usePermissionsStore } from '@/stores/permissions'
@@ -393,12 +393,21 @@ const duplicateAlert = computed(() => {
'border-t border-muted': index > 0,
}"
>
<RouterLink
:to="`/article/${article.id}/edit`"
class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3"
>
{{ article.title }}
</RouterLink>
<div class="flex min-w-0 flex-1 flex-col">
<RouterLink
:to="`/article/${article.id}/edit`"
class="truncate font-medium text-highlighted underline underline-offset-3"
>
{{ article.title }}
</RouterLink>
<span
v-if="split.showLanguages"
data-testid="usage-languages"
class="truncate text-xs text-muted"
>
{{ usageLanguages(article) }}
</span>
</div>
<MediaArticleStatusBadge
:published="article.published"
size="md"
+33 -6
View File
@@ -6,6 +6,7 @@ import FallbackChip from '@/components/FallbackChip.vue'
import SegmentedTabs from '@/components/SegmentedTabs.vue'
import type { Site } from '@/composables/useSites'
import { hostOf } from '@/lib/directory'
import { breadcrumbOf, descriptionHelp, type LocalizedSeoContext } from '@/lib/localizedSeo'
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
import {
PREVIEW_LIMITS,
@@ -26,6 +27,7 @@ const props = defineProps<{
input: SeoPreviewInput
site: Site | undefined
slug: string
localized?: LocalizedSeoContext | null
}>()
const device = defineModel<PreviewDevice>('device', { default: 'desktop' })
@@ -36,13 +38,35 @@ const items = computed(() => [
])
const fullTitle = computed(() => previewTitle(props.input))
const shownTitle = computed(() => truncateText(fullTitle.value, PREVIEW_LIMITS[device.value].title))
const withheldSiteText = computed(
() =>
props.localized?.resolved?.fallbacks.site_text_skipped === true &&
previewDescriptionSource(props.input) === 'site',
)
const shownDescription = computed(() =>
truncateText(previewDescription(props.input), PREVIEW_LIMITS[device.value].description),
truncateText(
withheldSiteText.value ? '' : previewDescription(props.input),
PREVIEW_LIMITS[device.value].description,
),
)
const cutOff = computed(() => titleIsCut(fullTitle.value, device.value))
const siteName = computed(() => siteDisplayName(props.site))
const siteInitial = computed(() => [...siteName.value][0] ?? '')
const host = computed(() => hostOf(props.site?.base_url ?? ''))
const localizedAddress = computed(() => {
const resolved = props.localized?.resolved
return props.localized?.pattern && resolved ? breadcrumbOf(resolved.canonical_url) : ''
})
const addressLine = computed(
() => localizedAddress.value || `${host.value} › ${props.slug.slice(0, SLUG_PATH_LIMIT)}`,
)
const localizedNote = computed(() =>
props.localized
? t('Shows the localized address and canonical. Description: {0}.', [
descriptionHelp(props.localized).replace(/\.$/, ''),
])
: '',
)
const chips = computed(() => {
const titleChip =
previewTitleSource(props.input) === 'title' ? [chipLabel('article_title')] : []
@@ -54,7 +78,9 @@ const chips = computed(() => {
<template>
<div class="flex flex-col gap-2" data-testid="search-preview">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-highlighted">{{ t('Search result') }}</span>
<span class="font-semibold text-highlighted">{{
localized ? t('How it can appear in search') : t('Search result')
}}</span>
<div class="w-37.5">
<SegmentedTabs v-model="device" compact :items="items" />
</div>
@@ -75,9 +101,7 @@ const chips = computed(() => {
>
<div class="flex min-w-0 flex-col">
<span class="text-xs/4 text-highlighted">{{ siteName }}</span>
<span class="truncate text-2xs/3.5 text-muted"
>{{ host }} › {{ slug.slice(0, SLUG_PATH_LIMIT) }}</span
>
<span class="truncate text-2xs/3.5 text-muted">{{ addressLine }}</span>
</div>
</div>
<span
@@ -92,7 +116,10 @@ const chips = computed(() => {
>{{ shownDescription }}</span
>
</UCard>
<div v-if="chips.length > 0" class="flex flex-wrap gap-1.5">
<p v-if="localized" class="text-xs/4.5 text-muted" data-testid="search-preview-note">
{{ localizedNote }}
</p>
<div v-if="chips.length > 0 && !localized" class="flex flex-wrap gap-1.5">
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
</div>
<span v-if="cutOff" class="text-xs text-muted" data-testid="search-preview-cut">{{
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import {
canonicalNote,
disabledNote,
socialLocaleLine,
socialLocaleNote,
type LocalizedSeoContext,
} from '@/lib/localizedSeo'
import { nameInSentence } from '@/lib/storyList'
const props = defineProps<{ context: LocalizedSeoContext }>()
const router = useRouter()
const CARD_CLASS = 'flex flex-col gap-3 border border-default bg-default p-4'
const NOTE_CLASS = 'text-xs/4.5 text-pretty text-muted'
const configured = computed(() => props.context.pattern !== null)
const canonical = computed(() => props.context.resolved?.canonical_url ?? '')
const alternates = computed(() => props.context.resolved?.alternates ?? [])
const hasLocale = computed(() => Boolean(props.context.resolved?.og.locale))
function openPublicUrl() {
router.push(`/settings/sites/${props.context.siteKey}/public-url`)
}
</script>
<template>
<div class="flex flex-col gap-4" data-testid="seo-language-cards">
<LocaleAlert
v-if="!configured"
tone="info"
icon="i-lucide-info"
:title="t('Public URL not configured')"
:description="
t(
'Add a public URL pattern for {0} to show page and canonical addresses. API links by article ID still work.',
[context.siteName],
)
"
>
<template #actions>
<LocaleButton small @click="openPublicUrl">
{{ t('Open Public URL settings') }}
</LocaleButton>
</template>
</LocaleAlert>
<template v-else>
<section :class="CARD_CLASS" data-testid="seo-canonical-card">
<div class="flex flex-wrap items-center gap-2">
<LocaleBadge tone="neutral" icon="i-lucide-link">{{
t('Canonical')
}}</LocaleBadge>
<span class="font-mono text-sm break-words text-highlighted">{{
canonical
}}</span>
</div>
<p :class="NOTE_CLASS">{{ canonicalNote(context) }}</p>
</section>
<section :class="CARD_CLASS" data-testid="seo-alternates-card">
<h3 class="font-semibold text-highlighted">{{ t('Alternate languages') }}</h3>
<p class="text-sm text-toned">
{{ t('Published versions in enabled languages only.') }}
</p>
<div
v-for="alternate in alternates"
:key="alternate.id"
class="flex items-center gap-2.5"
data-testid="seo-alternate-row"
>
<span
class="font-mono text-sm uppercase"
:aria-label="nameInSentence(alternate.lang)"
>{{ alternate.lang }}</span
>
<span class="min-w-0 flex-1 truncate text-sm text-toned">{{
alternate.url ?? alternate.slug
}}</span>
<LocaleBadge tone="success" icon="i-lucide-circle-check">{{
t('Published')
}}</LocaleBadge>
</div>
<p :class="NOTE_CLASS">{{ disabledNote(context) }}</p>
</section>
</template>
<section :class="CARD_CLASS" data-testid="seo-social-card">
<h3 class="font-semibold text-highlighted">{{ t('Social locale') }}</h3>
<div class="flex flex-wrap items-center gap-2">
<LocaleBadge tone="neutral" icon="i-lucide-share-2">og:locale</LocaleBadge>
<span
class="text-sm"
:class="hasLocale ? 'font-mono text-highlighted' : 'text-muted'"
>{{ socialLocaleLine(context) }}</span
>
</div>
<p :class="NOTE_CLASS">{{ socialLocaleNote(context) }}</p>
</section>
</div>
</template>
+110 -17
View File
@@ -9,6 +9,10 @@ import DirectoryPanel from '@/components/DirectoryPanel.vue'
import FallbackChip from '@/components/FallbackChip.vue'
import MediaField from '@/components/MediaField.vue'
import SearchPreview from '@/components/SearchPreview.vue'
import SeoLanguageCards from '@/components/SeoLanguageCards.vue'
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import LocaleInput from '@/components/localization/LocaleInput.vue'
import SeoAdvancedSection from '@/components/SeoAdvancedSection.vue'
import SeoSocialSection from '@/components/SeoSocialSection.vue'
import SlugField from '@/components/SlugField.vue'
@@ -22,6 +26,14 @@ import { useSeoPanelSlug } from '@/composables/useSeoPanelSlug'
import type { Site } from '@/composables/useSites'
import { SEO_LIMITS, charCount, type ArticleSeo } from '@/lib/articleSeo'
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
import {
altHelp,
descriptionBadge,
descriptionHelp,
pageLanguage,
titleHelp,
type LocalizedSeoContext,
} from '@/lib/localizedSeo'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
import {
@@ -61,6 +73,7 @@ const props = defineProps<{
saving: boolean
notice: DirectoryNotice | null
focus: SeoFocus | null
localized?: LocalizedSeoContext | null
}>()
const emit = defineEmits<{
@@ -72,6 +85,7 @@ const emit = defineEmits<{
go: [field: SuggestionField]
dismiss: [code: SuggestionCode]
restore: []
'edit-slug': []
}>()
const [DefineSuggestions, Suggestions] = createReusableTemplate()
@@ -85,6 +99,10 @@ const focusTarget = ref<SeoFocus | null>(props.focus)
const device = ref<PreviewDevice>('desktop')
const seo = computed(() => props.modelValue)
const pageName = computed(() => (props.localized ? pageLanguage(props.localized) : ''))
const panelTitle = computed(() =>
props.localized ? t('Search and sharing · {0}', [pageName.value]) : t('Search and sharing'),
)
const siteName = computed(() => siteDisplayName(props.site))
const previewInput = computed<SeoPreviewInput>(() => ({
seo: seo.value,
@@ -250,6 +268,7 @@ function submit() {
:input="previewInput"
:site="site"
:slug="displaySlug"
:localized="localized"
/>
<SocialCardPreview
:input="previewInput"
@@ -262,7 +281,57 @@ function submit() {
<DefineFields>
<div class="flex min-w-0 flex-col gap-4.5">
<div class="flex flex-col gap-2">
<template v-if="localized">
<p class="text-sm text-toned" data-testid="seo-language-scope">
{{
t('Applies only to the {0} version. Page language: {1}.', [
pageName,
localized.lang,
])
}}
</p>
<SeoLanguageCards :context="localized" />
<div
class="flex flex-col gap-3 border border-default bg-default p-4"
data-testid="seo-address-card"
>
<DirectoryField
:label="t('Address (slug)')"
:help="
t(
'Unique within {0} and {1}. Editing the title never changes the address.',
[siteName, pageName],
)
"
>
<LocaleInput
:model-value="savedSlug"
mono
readonly
locked
:aria-label="t('Address (slug)')"
/>
</DirectoryField>
<div class="flex flex-col items-start gap-1.5">
<LocaleButton icon="i-lucide-pencil" @click="emit('edit-slug')">
{{ t('Edit slug') }}
</LocaleButton>
<p class="text-xs/4.5 text-muted">
{{
t('Editing the title never changes the slug. Only Edit slug does.')
}}
</p>
<p class="text-xs/4.5 text-highlighted">
{{
t(
'Links to the old address will stop working. RenewCMS does not create redirects.',
)
}}
</p>
</div>
</div>
</template>
<div v-else class="flex flex-col gap-2">
<SlugField
:label="t('URL slug')"
:mode="slugMode"
@@ -299,9 +368,13 @@ function submit() {
<div class="flex flex-col gap-1.5">
<DirectoryField
:label="t('Meta title')"
optional
:help="t('Recommended 30 to 60 characters. This is a guide, not a limit.')"
:label="localized ? t('Page title') : t('Meta title')"
:optional="!localized"
:help="
localized
? titleHelp(localized)
: t('Recommended 30 to 60 characters. This is a guide, not a limit.')
"
>
<template #hint>
<SoftCounter
@@ -321,14 +394,21 @@ function submit() {
@update:model-value="typeText('seo_title', String($event))"
/>
</DirectoryField>
<FallbackChip v-if="!seo.seo_title" :label="chipLabel('article_title')" />
<FallbackChip
v-if="!seo.seo_title && !localized"
:label="chipLabel('article_title')"
/>
</div>
<div class="flex flex-col gap-1.5">
<DirectoryField
:label="t('Meta description')"
optional
:help="t('Recommended 70 to 160 characters.')"
:label="localized ? t('Description') : t('Meta description')"
:optional="!localized"
:help="
localized
? descriptionHelp(localized)
: t('Recommended 70 to 160 characters.')
"
>
<template #hint>
<SoftCounter
@@ -348,8 +428,19 @@ function submit() {
@update:model-value="typeText('seo_description', String($event))"
/>
</DirectoryField>
<LocaleBadge
v-if="localized && descriptionBadge(localized)"
:tone="localized.resolved?.fallbacks.site_text_skipped ? 'neutral' : 'info'"
:icon="
localized.resolved?.fallbacks.site_text_skipped
? 'i-lucide-minus'
: 'i-lucide-languages'
"
class="self-start"
>{{ descriptionBadge(localized) }}</LocaleBadge
>
<FallbackChip
v-if="descriptionChipLabel && !seo.seo_description"
v-else-if="descriptionChipLabel && !seo.seo_description && !localized"
:label="descriptionChipLabel"
/>
</div>
@@ -396,14 +487,16 @@ function submit() {
@update:model-value="setCover"
/>
<DirectoryField
v-if="seo.cover_image_id !== null"
:label="t('Alt text')"
v-if="seo.cover_image_id !== null || localized"
:label="localized ? t('Image alternative text') : t('Alt text')"
:help="
seo.cover_image_alt
? ''
: t(
'Needed before the cover counts as ready. Sent as image alt in the API.',
)
localized
? altHelp()
: seo.cover_image_alt
? ''
: t(
'Needed before the cover counts as ready. Sent as image alt in the API.',
)
"
>
<UInput
@@ -459,7 +552,7 @@ function submit() {
<DirectoryPanel
wide
:title="t('Search and sharing')"
:title="panelTitle"
:submit-label="t('Save changes')"
:saving="saving"
:submit-disabled="excerptTooLong"
@@ -1,16 +1,23 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ArticleDetails from '../ArticleDetails.vue'
import type { Article } from '@/composables/useArticles'
const toastAdd = vi.hoisted(() => vi.fn())
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd, toasts: { value: [] } }) }
})
afterEach(() => {
document.body.innerHTML = ''
})
function mountDetails(article: Omit<Article, 'images'>) {
function mountDetails(article: Omit<Article, 'images'>, multilingual = false) {
return mount(ArticleDetails, {
props: { article: article as Article },
props: { article: article as Article, multilingual },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
@@ -44,3 +51,49 @@ describe('An article from the API carries no images field', () => {
wrapper.unmount()
})
})
describe('The public API URL of a version', () => {
const french = { ...baseArticle, id: 14, lang: 'fr' }
async function copyFrom(wrapper: ReturnType<typeof mountDetails>) {
toastAdd.mockClear()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })
await wrapper.find('[aria-label="Copy the public API URL"]').trigger('click')
await Promise.resolve()
return { writeText, toast: toastAdd.mock.calls[0]?.[0] }
}
it('carries the language on a multilingual site', async () => {
const wrapper = mountDetails(french, true)
expect(wrapper.text()).toContain('/v1/article/14?lang=fr')
expect(wrapper.text()).toContain(
'Each language version has its own address. The URL carries the language.',
)
const copied = await copyFrom(wrapper)
expect(copied.writeText).toHaveBeenCalledWith(
`${window.location.origin}/v1/article/14?lang=fr`,
)
expect(copied.toast.title).toBe('API URL copied')
expect(copied.toast.description).toBe('/v1/article/14?lang=fr')
wrapper.unmount()
})
it('carries no lang for an unassigned version', () => {
const wrapper = mountDetails({ ...baseArticle, id: 15, lang: '' }, true)
expect(wrapper.text()).toContain('/v1/article/15')
expect(wrapper.text()).not.toContain('?lang=')
wrapper.unmount()
})
it('reads as before on a single-language site', async () => {
const wrapper = mountDetails(french)
expect(wrapper.text()).not.toContain('?lang=')
const copied = await copyFrom(wrapper)
expect(copied.toast.title).toBe('Link copied')
wrapper.unmount()
})
})
+5
View File
@@ -23,6 +23,7 @@ export type SaveOutcome =
| 'slug-taken'
| 'invalid-url'
| 'invalid-title'
| 'invalid-slug'
| 'conflict'
export interface ArticleMeta {
@@ -32,6 +33,7 @@ export interface ArticleMeta {
const SLUG_TAKEN_CODE = 'slug_taken'
const INVALID_URL_CODE = 'invalid_url'
const INVALID_SLUG_CODE = 'invalid_slug'
const REVISION_CONFLICT_CODE = 'revision_conflict'
export interface SaveOptions {
@@ -194,6 +196,9 @@ export function useArticleDraft(articleId: number, userId: number | null = null)
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
return 'slug-taken'
}
if (statusOf(err) === 400 && code === INVALID_SLUG_CODE) {
return 'invalid-slug'
}
return statusOf(err) === 422 && code === INVALID_URL_CODE ? 'invalid-url' : null
}
@@ -0,0 +1,125 @@
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import type { Article } from '@/composables/useArticles'
import type { SaveOutcome, useArticleDraft } from '@/composables/useArticleDraft'
import type { useEditorVersions } from '@/composables/useEditorVersions'
import { useResolvedSeo } from '@/composables/useResolvedSeo'
import type { Site } from '@/composables/useSites'
import type { LocalizedSeoContext } from '@/lib/localizedSeo'
interface LocalizedSeoOptions {
articleId: number
article: Ref<Article | null>
site: ComputedRef<Site | undefined>
versions: ReturnType<typeof useEditorVersions>
draft: ReturnType<typeof useArticleDraft>
panelOpen: Ref<boolean>
save: () => Promise<SaveOutcome>
}
export function useEditorLocalizedSeo(options: LocalizedSeoOptions) {
const toast = useLocalizedToast()
const resolvedSeo = useResolvedSeo(options.articleId)
const slugDialogOpen = ref(false)
const slugPending = ref(false)
const slugOutcome = ref<SaveOutcome | null>(null)
const takenSlug = ref('')
const context = computed<LocalizedSeoContext | null>(() => {
const site = options.site.value
const lang = options.article.value?.lang ?? ''
if (!options.versions.multilingual.value || !site || !lang) {
return null
}
const languages = options.versions.siteLanguages.value
return {
lang,
siteName: site.site_name || site.name,
siteKey: site.key,
pattern: site.public_url_pattern ?? null,
defaultLang: languages.find((language) => language.is_default)?.lang ?? '',
disabledLangs: languages
.filter((language) => !language.enabled && language.published_count > 0)
.map((language) => language.lang),
published: options.article.value?.is_online ?? false,
socialState:
languages.find((language) => language.lang === lang)?.social_locale_state ?? '',
resolved: resolvedSeo.resolved.value,
}
})
watch(
() => options.panelOpen.value && context.value !== null,
(wanted) => {
if (wanted) {
void resolvedSeo.load()
}
},
{ immediate: true },
)
const slugError = computed(() => {
if (slugOutcome.value === 'slug-taken') {
return t('The slug "{0}" is already used on {1}.', [
takenSlug.value,
context.value?.siteName ?? '',
])
}
return slugOutcome.value === 'invalid-slug'
? t('Lowercase letters, numbers and hyphens. Unique on {0}.', [
context.value?.siteName ?? '',
])
: ''
})
function openSlugDialog() {
slugOutcome.value = null
slugDialogOpen.value = true
}
function closeSlugDialog() {
slugDialogOpen.value = false
}
async function confirmSlug(slug: string) {
slugPending.value = true
takenSlug.value = slug
options.draft.editSeo({ ...options.draft.seo.value, slug })
const outcome = await options.save()
slugPending.value = false
slugOutcome.value = outcome
if (outcome === 'saved') {
slugDialogOpen.value = false
void resolvedSeo.load()
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Address changed')
},
get description() {
return t('The address of the {0} version is now {1}.', [
options.article.value?.lang ?? '',
slug,
])
},
}))
return
}
if (outcome === 'conflict') {
slugDialogOpen.value = false
}
}
return {
context,
slugDialogOpen,
slugPending,
slugError,
openSlugDialog,
closeSlugDialog,
confirmSlug,
reloadResolved: resolvedSeo.load,
}
}
+2
View File
@@ -46,6 +46,8 @@ export interface MediaUsageItem {
title?: string
published?: boolean
fields?: string[]
story_id?: number
lang?: string
content_type_key?: string
content_type_name?: string
first_field?: UsageFirstField
+26 -7
View File
@@ -21,6 +21,8 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
Articles: [
'GET /v1/article/',
'GET /v1/article/{id}',
'GET /v1/public/site/{key}/lang/{lang}/article/{slug}',
'GET /v1/public/site/{key}/story/{story_id}',
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
@@ -243,7 +245,7 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
}
describe('Catalogue routes', () => {
it('holds exactly the 43 key-callable routes in 9 groups, in order', () => {
it('holds exactly the 45 key-callable routes in 9 groups, in order', () => {
expect(API_GROUPS.map((group) => group.title)).toEqual([
'Articles',
'Search and sharing',
@@ -260,12 +262,12 @@ describe('Catalogue routes', () => {
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(43)
expect(allEndpoints()).toHaveLength(45)
})
it('counts the routes of each group', () => {
expect(API_GROUPS.map((group) => group.endpoints.length)).toEqual([
8, 2, 5, 5, 2, 11, 3, 6, 1,
10, 2, 5, 5, 2, 11, 3, 6, 1,
])
})
@@ -295,7 +297,7 @@ describe('Catalogue routes', () => {
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(9 + 43 + 40 + 6)
expect(described).toHaveLength(9 + 45 + 40 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
@@ -323,7 +325,12 @@ describe('Catalogue completeness', () => {
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(
`"${ORIGIN}${endpoint.path.replace('{key}', sampleKey(endpoint.path)).replace('{id}', String(endpoint.pathId))}"`,
`"${ORIGIN}${endpoint.path
.replace('{key}', sampleKey(endpoint.path))
.replace('{lang}', 'fr')
.replace('{slug}', 'comment-nous-chiffrons-une-renovation')
.replace('{story_id}', '5')
.replace('{id}', String(endpoint.pathId))}"`,
)
expect(examples.response).toBe(endpoint.response)
expect(examples.response.trim()).not.toBe('')
@@ -346,6 +353,8 @@ describe('Catalogue completeness', () => {
).toEqual([
'GET /v1/article/',
'GET /v1/article/{id}',
'GET /v1/public/site/{key}/lang/{lang}/article/{slug}',
'GET /v1/public/site/{key}/story/{story_id}',
'GET /v1/article/{id}/seo',
'GET /v1/site/{key}/sitemap',
])
@@ -386,6 +395,8 @@ describe('SEO output routes', () => {
'modified_at',
'fallbacks',
'json_ld',
'lang',
'alternates',
].sort(),
)
})
@@ -403,7 +414,7 @@ describe('SEO output routes', () => {
const sitemap = find('GET /v1/site/{key}/sitemap')
expect(sitemap.query.map((field) => field.name)).toEqual(['page', 'size'])
for (const item of parsed(sitemap)) {
expect(keysOf(item)).toEqual(['modified_at', 'noindex', 'url'])
expect(keysOf(item)).toEqual(['lang', 'modified_at', 'noindex', 'url'])
}
})
})
@@ -578,7 +589,15 @@ describe('Response samples use the real domain JSON keys', () => {
it('keeps id, title and published on usage items and adds type and fields', () => {
const usage = parsed(find('GET /v1/media/{id}/usage'))
expect(keysOf(usage[0])).toEqual(['fields', 'id', 'published', 'title', 'type'])
expect(keysOf(usage[0])).toEqual([
'fields',
'id',
'lang',
'published',
'story_id',
'title',
'type',
])
expect(usage.map((item: { type: string }) => item.type)).toEqual([
'article',
'author',
+115 -6
View File
@@ -169,6 +169,22 @@ const ARTICLE_LIST_QUERY: ApiField[] = [
},
]
const STORY_QUERY: ApiField[] = [
{
name: 'lang',
type: 'string',
required: false,
rule: 'A language tag such as fr or pt-BR. Default: the site default language.',
},
{
name: 'fallback',
type: 'string',
required: false,
rule: 'Only the value default: serve the published default-language version when the requested one is not published.',
},
FORMAT_QUERY,
]
const SITEMAP_QUERY: ApiField[] = [
{
name: 'page',
@@ -340,7 +356,8 @@ const resolvedSeo = () => ({
image_alt: 'A tiled bathroom wall',
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
site_name: 'Renew',
locale: 'en',
locale: 'en_GB',
locale_alternates: ['fr_FR'],
},
twitter: {
card: 'summary_large_image',
@@ -363,7 +380,13 @@ const resolvedSeo = () => ({
],
published_at: '2026-03-12T09:14:00Z',
modified_at: '2026-09-10T07:00:00Z',
fallbacks: { title: 'title', description: 'excerpt', image: 'cover', canonical: 'site' },
fallbacks: {
title: 'title',
description: 'excerpt',
image: 'cover',
canonical: 'pattern',
site_text_skipped: false,
},
json_ld: {
'@context': 'https://schema.org',
'@type': 'Article',
@@ -385,6 +408,16 @@ const resolvedSeo = () => ({
'@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
},
},
lang: 'en',
alternates: [
{
id: 13,
lang: 'fr',
story_id: 5,
slug: 'comment-nous-chiffrons-une-renovation',
url: 'https://blog.renew.example/fr/comment-nous-chiffrons-une-renovation',
},
],
})
const seoCheck = () => ({
@@ -396,16 +429,53 @@ const seoCheck = () => ({
slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' },
})
const PUBLIC_SAMPLE = {
lang: 'fr',
slug: 'comment-nous-chiffrons-une-renovation',
story: 5,
}
const alternates = () => [
{
id: 10,
lang: 'en',
story_id: 5,
slug: 'how-we-price-a-bathroom-renovation',
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
},
{
id: 11,
lang: 'de',
story_id: 5,
slug: 'so-kalkulieren-wir-eine-badsanierung',
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
},
]
const frenchVersion = (extra: Record<string, unknown> = {}) => ({
...article({
id: 12,
title: 'Comment nous chiffrons une rénovation',
slug: PUBLIC_SAMPLE.slug,
lang: 'fr',
story_id: 5,
}),
alternates: alternates(),
...extra,
})
const sitemapPage = () => [
{
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
modified_at: '2026-09-10T07:00:00Z',
noindex: false,
lang: 'en',
},
{
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
modified_at: '2026-09-09T07:00:00Z',
noindex: true,
lang: 'en',
},
]
@@ -758,7 +828,8 @@ export const API_GROUPS: ApiGroup[] = [
{
key: 'articles',
title: 'Articles',
description: "Read, write and publish articles. Titles can't be changed after creation.",
description:
'Read, write and publish articles. A title is edited like any other field and never changes the slug.',
endpoints: [
endpoint(
'art-list',
@@ -780,6 +851,38 @@ export const API_GROUPS: ApiGroup[] = [
json(article()),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-public-lookup',
'GET',
'/v1/public/site/{key}/lang/{lang}/article/{slug}',
'Exact lookup by site, language and slug. Returns the published version and its alternates, the other published versions of the article in enabled languages. It never falls back to another language, ignores any cookie or key, and never returns a draft.',
'Public',
[
e(400, 'invalid_language'),
e(400, FORMAT_INVALID),
e(404, 'article_not_found'),
e(404, 'site_not_found'),
],
json(frenchVersion()),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-public-story',
'GET',
'/v1/public/site/{key}/story/{story_id}',
'Resolve an article by its story_id in a language, with an explicit fallback to the default language only. The answer adds requested_lang and fallback to the version and its alternates. Without lang the site default language is requested.',
'Public',
[
e(400, 'invalid_language'),
e(400, BODY_MISMATCH),
e(400, FORMAT_INVALID),
e(400, ID_FORMAT),
e(404, 'language_not_available'),
e(404, 'site_not_found'),
],
json(frenchVersion({ requested_lang: 'fr', fallback: false })),
{ query: STORY_QUERY },
),
endpoint(
'art-create',
'POST',
@@ -1016,7 +1119,7 @@ export const API_GROUPS: ApiGroup[] = [
'site-sitemap',
'GET',
'/v1/site/{key}/sitemap',
'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.',
'List the published URLs of a site, one per version in an enabled language, by id ascending, as an array of { url, modified_at, noindex, lang }. The total is in the X-Total-Count header.',
'Public',
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
json(sitemapPage()),
@@ -1407,7 +1510,7 @@ export const API_GROUPS: ApiGroup[] = [
'med-use',
'GET',
'/v1/media/{id}/usage',
'List the articles, authors and sites that use an image, with the places where each one uses it.',
'List the articles, authors and sites that use an image, with the places where each one uses it. There is one article item per version, with its story_id and lang.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json([
@@ -1417,6 +1520,8 @@ export const API_GROUPS: ApiGroup[] = [
title: ARTICLE_TITLE,
published: true,
fields: ['body', 'cover'],
story_id: 5,
lang: 'en',
},
{ type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] },
{ type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] },
@@ -1983,7 +2088,11 @@ function sampleKey(path: string) {
}
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
const keyed = item.path.replace('{key}', sampleKey(item.path))
const keyed = item.path
.replace('{key}', sampleKey(item.path))
.replace('{lang}', PUBLIC_SAMPLE.lang)
.replace('{slug}', PUBLIC_SAMPLE.slug)
.replace('{story_id}', String(PUBLIC_SAMPLE.story))
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
}
+2 -2
View File
@@ -1,10 +1,12 @@
import { articleListMessages } from './articleListMessages'
import { editorMessages } from './editorMessages'
import { localizationMessages } from './localizationMessages'
import { surfaceMessages } from './surfaceMessages'
import { translationFlowMessages } from './translationFlowMessages'
export const messages = {
...localizationMessages,
...surfaceMessages,
...articleListMessages,
...editorMessages,
...translationFlowMessages,
@@ -1614,8 +1616,6 @@ export const messages = {
'newest, oldest, name, largest or total_size. Default newest.':
'newest, oldest, name, largest ou total_size. Par défaut newest.',
'with or without. Default both.': 'with ou without. Par défaut les deux.',
"Read, write and publish articles. Titles can't be changed after creation.":
'Lire, écrire et publier des articles. Les titres ne peuvent pas être modifiés après la création.',
"3 to 200 characters. Can't be changed later.":
'De 3 à 200 caractères. Ne peut pas être modifié ensuite.',
'Up to 10 000 characters.': '10 000 caractères maximum.',
+1
View File
@@ -43,6 +43,7 @@ const SAME_IN_FRENCH = new Set([
'{0} suggestions',
'Suggestions',
'Mobile',
'Description',
])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
+104
View File
@@ -0,0 +1,104 @@
export const surfaceMessages = {
'Created {0} version of "{1}"': 'Version en {0} créée : « {1} »',
'Failed to create {0} version of "{1}"': 'Échec de la création de la version en {0} : « {1} »',
'Edited {0} version of "{1}"': 'Version en {0} modifiée : « {1} »',
'Failed to edit {0} version of "{1}"':
'Échec de la modification de la version en {0} : « {1} »',
'Published {0} version of "{1}"': 'Version en {0} publiée : « {1} »',
'Failed to publish {0} version of "{1}"':
'Échec de la publication de la version en {0} : « {1} »',
'Unpublished {0} version of "{1}"': 'Version en {0} dépubliée : « {1} »',
'Failed to unpublish {0} version of "{1}"':
'Échec de la dépublication de la version en {0} : « {1} »',
'Deleted {0} version of "{1}"': 'Version en {0} supprimée : « {1} »',
'Failed to delete {0} version of "{1}"':
'Échec de la suppression de la version en {0} : « {1} »',
'API URL copied': 'URL de l’API copiée',
'Add a public URL pattern for {0} to show page and canonical addresses. API links by article ID still work.':
'Ajoutez un modèle d’URL publique pour {0} afin d’afficher les adresses de page et canoniques. Les liens d’API par identifiant d’article fonctionnent toujours.',
'Address (slug)': 'Adresse (slug)',
'Address changed': 'Adresse modifiée',
'Alternate languages': 'Langues alternatives',
'Applies only to the {0} version. Page language: {1}.':
'S’applique uniquement à la version en {0}. Langue de la page : {1}.',
'Built from the Public URL pattern {0}. Each language points to itself unless you set a canonical override.':
'Construite à partir du modèle d’URL publique {0}. Chaque langue pointe vers elle-même, sauf si vous définissez une canonique manuelle.',
Canonical: 'Canonique',
'Change address': 'Changer l’adresse',
'Drafts and the disabled languages {0} are left out.':
'Les brouillons et les langues désactivées ({0}) sont exclus.',
'Drafts and the disabled {0} language are left out.':
'Les brouillons et la langue désactivée ({0}) sont exclus.',
'Drafts are left out.': 'Les brouillons sont exclus.',
'Each language version has its own address. The URL carries the language.':
'Chaque version linguistique a sa propre adresse. L’URL porte la langue.',
'Edit slug for the {0} version': 'Modifier le slug de la version en {0}',
'Editing the title never changes the slug.': 'Modifier le titre ne change jamais le slug.',
'Editing the title never changes the slug. Only Edit slug does.':
'Modifier le titre ne change jamais le slug. Seul « Modifier le slug » le fait.',
'Fallback: {0} body': 'Repli : corps en {0}',
'Fallback: {0} excerpt': 'Repli : extrait en {0}',
'Fallback: {0} site description': 'Repli : description du site en {0}',
'Fallback: {0} title': 'Repli : titre en {0}',
'How it can appear in search': 'Aperçu dans les résultats de recherche',
'Image alternative text': 'Texte alternatif de l’image',
'Links to the old address will stop working. RenewCMS does not create redirects.':
'Les liens vers l’ancienne adresse cesseront de fonctionner. RenewCMS ne crée pas de redirections.',
'No fallback': 'Aucun repli',
'No fallback. Empty. {0} site default not used. The tag is left out.':
'Aucun repli. Vide. Le texte du site par défaut en {0} n’est pas utilisé. La balise est omise.',
'Open Public URL settings': 'Ouvrir les réglages d’URL publique',
'Page title': 'Titre de la page',
'Published versions in enabled languages only.':
'Versions publiées dans les langues activées uniquement.',
'Search and sharing · {0}': 'Recherche et partage · {0}',
'Set by an owner for {0} in {1} languages. A region is never guessed from the language.':
'Défini par un propriétaire pour la langue {0} dans les langues de {1}. Une région n’est jamais déduite de la langue.',
'Set by hand as a canonical override on this version.':
'Définie à la main comme canonique de cette version.',
'Shows the localized address and canonical. Description: {0}.':
'Affiche l’adresse localisée et la canonique. Description : {0}.',
'Social locale not set': 'Locale sociale non définie',
'Tag left out': 'Balise omise',
'Taken from the region of the language code or of the site locale. An owner can set another in {0} languages.':
'Issue de la région du code de langue ou de la locale du site. Un propriétaire peut en définir une autre dans les langues de {0}.',
'The address changes for {0} only.': 'L’adresse ne change que pour la version en {0}.',
'The address of the {0} version is now {1}.':
'L’adresse de la version en {0} est maintenant {1}.',
'This version is published': 'Cette version est publiée',
'Unique within {0} and {1}. Editing the title never changes the address.':
'Unique dans {0} et en {1}. Modifier le titre ne change jamais l’adresse.',
'Uses site text': 'Utilise le texte du site',
'Written per language.': 'Rédigé pour chaque langue.',
'{0} has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in {1} languages.':
'{0} n’a pas de région dans son code et aucune locale sociale n’est définie, donc og:locale et og:locale:alternate sont omis. Un propriétaire peut en définir une dans les langues de {1}.',
Languages: 'Langues',
'Read one published language of an article, or resolve an article in a language. Both requests are public.':
'Lisez une langue publiée d’un article, ou résolvez un article dans une langue. Les deux requêtes sont publiques.',
'1. Exact lookup: site + language + slug': '1. Recherche exacte : site + langue + slug',
'Matches the published version with that slug in that language, or returns article_not_found. It never falls back, because a slug belongs to one language.':
'Trouve la version publiée avec ce slug dans cette langue, ou renvoie article_not_found. Aucun repli, car un slug appartient à une seule langue.',
'2. Resolve by article group, with fallback': '2. Résolution par groupe d’articles, avec repli',
'Use story_id from any response or from alternates[]. French published: lang "fr". French missing or unpublished with fallback=default: the published default, with "lang": "en", "requested_lang": "fr", "fallback": true. Without fallback=default, a missing French is language_not_available. No other language is tried, and drafts are never returned.':
'Utilisez story_id d’une réponse ou de alternates[]. Français publié : lang "fr". Français absent ou non publié avec fallback=default : la version par défaut publiée, avec "lang": "en", "requested_lang": "fr", "fallback": true. Sans fallback=default, un français absent donne language_not_available. Aucune autre langue n’est essayée et les brouillons ne sont jamais renvoyés.',
'3. Errors': '3. Erreurs',
'article_not_found: no published version has that slug in that language. language_not_available: the requested language and the allowed fallback are both unavailable. site_not_found: no site has that key. invalid_language: the language is not a language tag.':
'article_not_found : aucune version publiée n’a ce slug dans cette langue. language_not_available : la langue demandée et le repli autorisé sont indisponibles. site_not_found : aucun site n’a cette clé. invalid_language : la langue n’est pas une balise de langue.',
'Existing, unchanged': 'Existant, inchangé',
'Each article ID is one language version. Article-ID links keep working.':
'Chaque identifiant d’article est une version linguistique. Les liens par identifiant d’article continuent de fonctionner.',
'Read, write and publish articles. A title is edited like any other field and never changes the slug.':
'Lire, écrire et publier des articles. Un titre se modifie comme n’importe quel champ et ne change jamais le slug.',
'Exact lookup by site, language and slug. Returns the published version and its alternates, the other published versions of the article in enabled languages. It never falls back to another language, ignores any cookie or key, and never returns a draft.':
'Recherche exacte par site, langue et slug. Renvoie la version publiée et ses alternatives, les autres versions publiées de l’article dans les langues activées. Aucun repli vers une autre langue, tout cookie ou clé est ignoré et aucun brouillon n’est renvoyé.',
'A language tag such as fr or pt-BR. Default: the site default language.':
'Une balise de langue comme fr ou pt-BR. Par défaut : la langue par défaut du site.',
'Only the value default: serve the published default-language version when the requested one is not published.':
'Uniquement la valeur default : renvoie la version publiée dans la langue par défaut quand celle demandée n’est pas publiée.',
'List the published URLs of a site, one per version in an enabled language, by id ascending, as an array of { url, modified_at, noindex, lang }. The total is in the X-Total-Count header.':
'Liste les URL publiées d’un site, une par version dans une langue activée, par identifiant croissant, sous forme de tableau de { url, modified_at, noindex, lang }. Le total est dans l’en-tête X-Total-Count.',
'List the articles, authors and sites that use an image, with the places where each one uses it. There is one article item per version, with its story_id and lang.':
'Liste les articles, auteurs et sites qui utilisent une image, avec les endroits où chacun l’utilise. Il y a un élément d’article par version, avec son story_id et sa lang.',
'Resolve an article by its story_id in a language, with an explicit fallback to the default language only. The answer adds requested_lang and fallback to the version and its alternates. Without lang the site default language is requested.':
'Résout un article par son story_id dans une langue, avec un repli explicite vers la langue par défaut uniquement. La réponse ajoute requested_lang et fallback à la version et à ses alternatives. Sans lang, la langue par défaut du site est demandée.',
} as const
@@ -625,3 +625,48 @@ describe('Roles sync: the six role events in EN and FR', () => {
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
})
})
describe('Sentences name the language of an article version', () => {
const actions = ['create', 'edit', 'publish', 'unpublish', 'delete']
const english: Record<string, string> = {
create: 'Created French version of "Spring release"',
edit: 'Edited French version of "Spring release"',
publish: 'Published French version of "Spring release"',
unpublish: 'Unpublished French version of "Spring release"',
delete: 'Deleted French version of "Spring release"',
}
it.each(actions)('names French in article.%s', (action) => {
const text = composeSentence(item({ action: `article.${action}`, params: { lang: 'fr' } }))
.map((segment) => segment.text)
.join('')
expect(text).toBe(english[action])
})
it('reads in French from the same params', () => {
setLocale('fr')
const text = composeSentence(item({ params: { lang: 'fr' } }))
.map((segment) => segment.text)
.join('')
expect(text).toBe('Version en français publiée : « Spring release »')
})
it('names the failure of a version', () => {
const text = composeSentence(item({ outcome: 'failed', params: { lang: 'de' } }))
.map((segment) => segment.text)
.join('')
expect(text).toBe('Failed to publish German version of "Spring release"')
})
it('keeps the sentence of an event without a language', () => {
const text = composeSentence(item({ params: {} }))
.map((segment) => segment.text)
.join('')
expect(text).toBe('Published the article Spring release')
})
})
+4 -4
View File
@@ -65,9 +65,9 @@ describe('index', () => {
])
})
it('holds 89 endpoints: 43 documented, 40 session only and 6 public auth', () => {
expect(index.endpoints).toHaveLength(89)
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(43)
it('holds 91 endpoints: 45 documented, 40 session only and 6 public auth', () => {
expect(index.endpoints).toHaveLength(91)
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(45)
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(40)
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
})
@@ -202,7 +202,7 @@ describe('matching', () => {
describe('custom content types', () => {
it.each([
['media', 16, 1],
['404', 24, 1],
['404', 26, 1],
['login', 1, 1],
['entry', 7, 1],
['capacity', 2, 0],
+37
View File
@@ -5,6 +5,7 @@ import { sizeLabel } from '@/lib/activityRetention'
import { formatSize } from '@/lib/mediaFormat'
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { causeText } from '@/lib/variants/variantCauses'
import { nameInSentence } from '@/lib/storyList'
export interface SentenceSegment {
text: string
@@ -22,6 +23,34 @@ const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
const FRENCH_ELIDING_ACTIONS = ['user.change_role', 'user.remove']
const FRENCH_VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
const LANGUAGE_CATALOGUE: Record<string, Template> = {
'article.create': {
success: 'Created {0} version of "{1}"',
failed: 'Failed to create {0} version of "{1}"',
},
'article.edit': {
success: 'Edited {0} version of "{1}"',
failed: 'Failed to edit {0} version of "{1}"',
},
'article.publish': {
success: 'Published {0} version of "{1}"',
failed: 'Failed to publish {0} version of "{1}"',
},
'article.unpublish': {
success: 'Unpublished {0} version of "{1}"',
failed: 'Failed to unpublish {0} version of "{1}"',
},
'article.delete': {
success: 'Deleted {0} version of "{1}"',
failed: 'Failed to delete {0} version of "{1}"',
},
}
function versionLanguage(item: ActivityItem) {
const tag = item.params.lang
return typeof tag === 'string' && tag !== '' && item.action in LANGUAGE_CATALOGUE ? tag : null
}
const CATALOGUE: Record<string, Template> = {
'article.create': {
success: 'Created the article {0}',
@@ -317,6 +346,10 @@ function reassignParts(item: ActivityItem): SentenceParts {
}
function resolveTemplate(item: ActivityItem): MessageKey | null {
if (versionLanguage(item) !== null) {
const named = LANGUAGE_CATALOGUE[item.action]!
return item.outcome === 'failed' ? (named.failed ?? named.success) : named.success
}
const template = CATALOGUE[item.action]
if (!template) return null
if (item.action === 'role.reassign') return reassignParts(item).template
@@ -339,6 +372,10 @@ function resolveTemplate(item: ActivityItem): MessageKey | null {
}
function slotValues(item: ActivityItem, template: MessageKey) {
const tag = versionLanguage(item)
if (tag !== null) {
return [nameInSentence(tag), TARGET_SLOT]
}
if (item.action === 'role.reassign') {
return reassignParts(item).values
}
+120
View File
@@ -0,0 +1,120 @@
import { locale, t } from '@/i18n'
import type { ResolvedSeo } from '@/lib/seoTypes'
import { languageName } from '@/lib/siteLanguages'
import { nameInSentence } from '@/lib/storyList'
export interface LocalizedSeoContext {
lang: string
siteName: string
siteKey: string
pattern: string | null
defaultLang: string
disabledLangs: string[]
published: boolean
socialState: string
resolved: ResolvedSeo | null
}
export function pageLanguage(context: LocalizedSeoContext) {
return languageName(context.lang, locale.value)
}
export function breadcrumbOf(url: string) {
try {
const parsed = new URL(url)
const segments = parsed.pathname.split('/').filter(Boolean)
return [parsed.host, ...segments].join(' › ')
} catch {
return url
}
}
export function canonicalNote(context: LocalizedSeoContext) {
if (context.resolved?.fallbacks.canonical === 'canonical_url') {
return t('Set by hand as a canonical override on this version.')
}
return t(
'Built from the Public URL pattern {0}. Each language points to itself unless you set a canonical override.',
[context.pattern ?? ''],
)
}
export function disabledNote(context: LocalizedSeoContext) {
if (context.disabledLangs.length === 0) {
return t('Drafts are left out.')
}
const names = context.disabledLangs.map((tag) => nameInSentence(tag))
const list = names.join(', ')
return names.length === 1
? t('Drafts and the disabled {0} language are left out.', [list])
: t('Drafts and the disabled languages {0} are left out.', [list])
}
export function socialLocaleLine(context: LocalizedSeoContext) {
const value = context.resolved?.og.locale ?? ''
if (!value) {
return t('Social locale not set')
}
return context.socialState === 'derived' ? t('Derived: {0}', [value]) : t('Set: {0}', [value])
}
export function socialLocaleNote(context: LocalizedSeoContext) {
const name = pageLanguage(context)
if (!context.resolved?.og.locale) {
return t(
'{0} has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in {1} languages.',
[name, context.siteName],
)
}
if (context.socialState === 'derived') {
return t(
'Taken from the region of the language code or of the site locale. An owner can set another in {0} languages.',
[context.siteName],
)
}
return t(
'Set by an owner for {0} in {1} languages. A region is never guessed from the language.',
[name, context.siteName],
)
}
export function titleHelp(context: LocalizedSeoContext) {
const source = context.resolved?.fallbacks.title
return source === 'title' ? t('Fallback: {0} title', [nameInSentence(context.lang)]) : ''
}
export function descriptionHelp(context: LocalizedSeoContext) {
const fallbacks = context.resolved?.fallbacks
if (!fallbacks) {
return ''
}
const name = nameInSentence(context.lang)
switch (fallbacks.description) {
case 'excerpt':
return t('Fallback: {0} excerpt', [name])
case 'body':
return t('Fallback: {0} body', [name])
case 'site':
return t('Fallback: {0} site description', [name])
case 'none':
return fallbacks.site_text_skipped
? t('No fallback. Empty. {0} site default not used. The tag is left out.', [
nameInSentence(context.defaultLang),
])
: t('No fallback')
default:
return ''
}
}
export function descriptionBadge(context: LocalizedSeoContext) {
const fallbacks = context.resolved?.fallbacks
if (fallbacks?.description === 'site') {
return t('Uses site text')
}
return fallbacks?.site_text_skipped ? t('Tag left out') : ''
}
export function altHelp() {
return t('Written per language.')
}
+50 -6
View File
@@ -2,15 +2,23 @@ import { t } from '@/i18n'
import type { MediaUsageItem, UsageState } from '@/composables/useMedia'
import { describeCell, plainCellText } from '@/lib/entryCells'
import { pluralArticles } from '@/lib/mediaFormat'
import { nameInSentence } from '@/lib/storyList'
const ENTRY_ROWS_SHOWN = 3
const ARTICLE_TYPE = 'article'
const ENTRY_TYPE = 'entry'
export interface UsageVersion {
id: number
lang: string
published: boolean
}
export interface UsageArticle {
id: number
title: string
published: boolean
versions: UsageVersion[]
}
export interface UsageEntry {
@@ -28,6 +36,7 @@ export interface UsageOther {
export interface UsageSplit {
articles: UsageArticle[]
showLanguages: boolean
others: UsageOther[]
entries: UsageEntry[]
entryCount: number
@@ -72,14 +81,41 @@ function toEntry(item: MediaUsageItem): UsageEntry {
}
}
export function splitUsage(items: MediaUsageItem[]): UsageSplit {
const articles = items
.filter((item) => item.type === undefined || item.type === ARTICLE_TYPE)
.map((item) => ({
function groupArticles(items: MediaUsageItem[]): UsageArticle[] {
const groups = new Map<string, UsageArticle>()
for (const item of items) {
const key = item.lang && item.story_id ? `story-${item.story_id}` : `article-${item.id}`
const version = {
id: item.id,
lang: item.lang ?? '',
published: item.published === true,
}
const existing = groups.get(key)
if (existing) {
existing.versions.push(version)
existing.published = existing.published || version.published
continue
}
groups.set(key, {
id: item.id,
title: item.title ?? '',
published: item.published === true,
}))
published: version.published,
versions: [version],
})
}
return [...groups.values()]
}
function showsLanguages(articles: UsageArticle[]) {
const tags = new Set(articles.flatMap((article) => article.versions.map((v) => v.lang)))
tags.delete('')
return tags.size > 1 || articles.some((article) => article.versions.length > 1)
}
export function splitUsage(items: MediaUsageItem[]): UsageSplit {
const articles = groupArticles(
items.filter((item) => item.type === undefined || item.type === ARTICLE_TYPE),
)
const entryItems = items.filter((item) => item.type === ENTRY_TYPE)
const others = items
.filter((item) => item.type === 'author' || item.type === 'site')
@@ -90,6 +126,7 @@ export function splitUsage(items: MediaUsageItem[]): UsageSplit {
}))
return {
articles,
showLanguages: showsLanguages(articles),
others,
entries: entryItems.slice(0, ENTRY_ROWS_SHOWN).map(toEntry),
entryCount: entryItems.length,
@@ -155,3 +192,10 @@ export function deleteCopy(filename: string, usedIn: number, state: UsageState |
split: known ? split : null,
}
}
export function usageLanguages(article: UsageArticle) {
return article.versions
.filter((version) => version.lang !== '')
.map((version) => nameInSentence(version.lang))
.join(', ')
}
+3
View File
@@ -0,0 +1,3 @@
export const PUBLIC_LOOKUP_ROUTE = 'GET /v1/public/site/{key}/lang/{lang}/article/{slug}'
export const PUBLIC_STORY_ROUTE =
'GET /v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default'
+18 -1
View File
@@ -54,6 +54,7 @@ export interface ResolvedSeo {
url: string
site_name: string
locale: string
locale_alternates?: string[]
}
twitter: {
card: string
@@ -66,6 +67,22 @@ export interface ResolvedSeo {
authors: { name: string; url: string; same_as: string[] }[]
published_at: string | null
modified_at: string
fallbacks: { title: string; description: string; image: string; canonical: string }
fallbacks: {
title: string
description: string
image: string
canonical: string
site_text_skipped?: boolean
}
json_ld: Record<string, unknown>
lang?: string
alternates?: SeoAlternate[]
}
export interface SeoAlternate {
id: number
lang: string
story_id: number
slug: string
url: string | null
}
+24
View File
@@ -12,6 +12,7 @@ import ArticleDetails from '@/components/ArticleDetails.vue'
import MetaSection from '@/components/MetaSection.vue'
import SearchSharingSection from '@/components/SearchSharingSection.vue'
import SeoPanel from '@/components/SeoPanel.vue'
import EditSlugDialog from '@/components/EditSlugDialog.vue'
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
import EditorActions from '@/components/EditorActions.vue'
import EditorHeader from '@/components/EditorHeader.vue'
@@ -43,6 +44,7 @@ import { useVersionSwitch } from '@/composables/useVersionSwitch'
import { useEditorFocus } from '@/composables/useEditorFocus'
import { useEditorImages } from '@/composables/useEditorImages'
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
import { useEditorLocalizedSeo } from '@/composables/useEditorLocalizedSeo'
import { useSeoDismissals } from '@/composables/useSeoDismissals'
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
import { useSites, type Site } from '@/composables/useSites'
@@ -272,6 +274,16 @@ const capabilities = computed(() => article.value?.capabilities ?? [])
const canEdit = computed(() => holds(capabilities.value, 'edit'))
const versions = useEditorVersions(article, sites, capabilities)
const localizedSeo = useEditorLocalizedSeo({
articleId,
article,
site: computed(() => siteOfArticle.value),
versions,
draft,
panelOpen,
save: () => runSave(),
})
const offline = computed(() => draft.state.value === 'offline')
const unsavedChanges = computed(
() => draft.businessState.value === 'dirty' || draft.businessState.value === 'error',
@@ -849,6 +861,8 @@ useUnsavedChangesGuard({
:saving="panelSaving"
:notice="panelNotice"
:focus="panelFocus"
:localized="localizedSeo.context.value"
@edit-slug="localizedSeo.openSlugDialog"
@update:model-value="updatePanelValues"
@check="onPanelCheck"
@submit="saveSeoPanel"
@@ -858,6 +872,16 @@ useUnsavedChangesGuard({
@dismiss="dismissSuggestion"
@restore="restoreDismissed"
/>
<EditSlugDialog
v-if="localizedSeo.slugDialogOpen.value && localizedSeo.context.value"
:language="versions.displayName.value"
:slug="draft.savedSeo.value.slug"
:published="article.is_online"
:pending="localizedSeo.slugPending.value"
:error="localizedSeo.slugError.value"
@close="localizedSeo.closeSlugDialog"
@confirm="localizedSeo.confirmSlug"
/>
<ApiPreviewDialog
v-model:open="apiPreviewOpen"
:article-id="articleId"
+43 -2
View File
@@ -10,6 +10,8 @@ import NewArticleModal from '@/components/NewArticleModal.vue'
import RecentActivityCard from '@/components/RecentActivityCard.vue'
import UpdateBanner from '@/components/UpdateBanner.vue'
import { useArticles, type Article, type ArticleCounts } from '@/composables/useArticles'
import { useSites } from '@/composables/useSites'
import { useSiteLanguages } from '@/composables/useSiteLanguages'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
import { useUpdateStore } from '@/stores/update'
@@ -27,7 +29,11 @@ const toast = useLocalizedToast()
const session = useSessionStore()
const permissions = usePermissionsStore()
const update = useUpdateStore()
const { listArticles } = useArticles()
const { listStories } = useArticles()
const { listAllSites } = useSites()
const { getLanguages } = useSiteLanguages()
const defaultLangBySite = ref<Record<number, string>>({})
const loadState = ref<LoadState>('loading')
const articles = ref<Article[]>([])
@@ -93,6 +99,34 @@ function clearSkeletonTimer() {
}
}
async function loadDefaultLanguages(items: Article[]) {
const siteIds = [
...new Set(items.filter((item) => item.lang && item.site_id).map((item) => item.site_id!)),
]
if (siteIds.length === 0) {
return
}
try {
const sites = await listAllSites()
const known: Record<number, string> = {}
for (const site of sites.filter((candidate) => siteIds.includes(candidate.id))) {
const languages = await getLanguages(site.key)
known[site.id] = languages.default ?? ''
}
defaultLangBySite.value = known
} catch {
defaultLangBySite.value = {}
}
}
function languageCode(article: Article) {
const defaultLang = defaultLangBySite.value[article.site_id ?? 0]
if (!article.lang || !defaultLang || article.lang === defaultLang) {
return ''
}
return article.lang.toUpperCase()
}
async function loadArticles() {
loadState.value = 'loading'
showSkeleton.value = false
@@ -102,7 +136,7 @@ async function loadArticles() {
}, SKELETON_DELAY_MS)
try {
const result = await listArticles({
const result = await listStories({
page: 1,
size: RECENT_LIST_SIZE,
sort: 'updated_at:desc',
@@ -110,6 +144,7 @@ async function loadArticles() {
articles.value = result.items
counts.value = result.counts
loadState.value = result.counts.all === 0 ? 'empty' : 'default'
void loadDefaultLanguages(result.items)
} catch {
loadState.value = 'error'
} finally {
@@ -277,6 +312,12 @@ onMounted(loadArticles)
>
{{ article.title }}
</span>
<span
v-if="languageCode(article)"
data-testid="language-code"
class="border border-accented px-1.5 font-mono text-xs text-highlighted"
>{{ languageCode(article) }}</span
>
<ArticleStatusBadge :online="article.is_online" />
<span class="hidden w-27.5 text-right text-muted lg:inline">
{{ formatRelativeDate(article.updated_at) }}
+16 -2
View File
@@ -17,6 +17,7 @@ import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import IntegrationImages from '@/components/IntegrationImages.vue'
import IntegrationLanguages from '@/components/IntegrationLanguages.vue'
import SectionIndex from '@/components/SectionIndex.vue'
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import {
@@ -50,13 +51,20 @@ const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.
const { before: groupsBeforeImages, after: groupsAfterImages } = groupsAroundImages(API_GROUPS)
const LANGUAGES_AFTER_GROUP = 'seo'
function groupSection(group: ApiGroup) {
return { id: group.key, label: translate(group.title) }
}
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...groupsBeforeImages.map(groupSection),
...groupsBeforeImages.flatMap((group) => [
groupSection(group),
...(group.key === LANGUAGES_AFTER_GROUP
? [{ id: 'languages', label: t('Languages') }]
: []),
]),
{ id: 'images', label: t('Images and variants') },
...groupsAfterImages.map(groupSection),
{ id: 'session', label: t('Session only') },
@@ -166,7 +174,12 @@ const fieldTypeColumns = computed(() => [
])
const pageBlocks = [
...groupsBeforeImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
...groupsBeforeImages.flatMap((group) => [
{ kind: 'group' as const, key: group.key, group },
...(group.key === LANGUAGES_AFTER_GROUP
? [{ kind: 'languages' as const, key: 'languages', group: null }]
: []),
]),
{ kind: 'images' as const, key: 'images', group: null },
...groupsAfterImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
]
@@ -524,6 +537,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
<template v-for="block in pageBlocks" :key="block.key">
<IntegrationImages v-if="block.kind === 'images'" :origin="origin" />
<IntegrationLanguages v-else-if="block.kind === 'languages'" />
<section
v-else
:data-anchor="block.group.key"
@@ -0,0 +1,364 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale } from '@/i18n'
import { failWith, callsTo, press, typeInto, unmountRoute } from './localizationKit'
import { articleOf, mountEditor, IDS, type EditorFixture } from './editorKit'
import { openPanel, panelRoot } from './seoTestKit'
import { textOf } from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
const SLUG = 'comment-nous-chiffrons-une-renovation'
const SITE = {
id: 1,
name: 'Journal',
key: 'journal',
site_name: 'Journal',
title_template: '%title% | %site%',
default_description: 'Renovation notes.',
base_url: 'https://journal.example.com',
default_social_image_id: null,
default_robots: 'index',
locale: 'en-GB',
is_default: true,
article_count: 4,
public_url_pattern: PATTERN,
}
const ALTERNATES = [
{
id: 10,
lang: 'en',
story_id: 5,
slug: 'how-we-price-a-bathroom-renovation',
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
},
{
id: 11,
lang: 'de',
story_id: 5,
slug: 'so-kalkulieren-wir-eine-badsanierung',
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
},
]
function resolved(overrides: Record<string, unknown> = {}, fallbacks = {}) {
return {
title: 'Couts de renovation | Journal',
description: '',
canonical_url: `https://journal.example.com/fr/${SLUG}`,
robots: 'index, follow',
og: {
type: 'article',
title: '',
description: '',
image: '',
image_alt: '',
url: '',
site_name: 'Journal',
locale: 'fr_FR',
locale_alternates: [],
},
twitter: { card: 'summary' },
image: { url: '', alt: '', source: 'none' },
authors: [],
published_at: null,
modified_at: '2026-09-26T11:02:00Z',
fallbacks: {
title: 'title',
description: 'none',
image: 'none',
canonical: 'pattern',
site_text_skipped: true,
...fallbacks,
},
json_ld: {},
lang: 'fr',
alternates: ALTERNATES,
...overrides,
}
}
function fixture(
site: Record<string, unknown> = {},
seo: unknown = resolved(),
options: EditorFixture = {},
): EditorFixture {
return {
...options,
extra: {
'GET /v1/site/': [{ ...SITE, ...site }],
'GET /v1/article/12': { ...articleOf(options), slug: SLUG },
'GET /v1/article/12/seo': seo,
...options.extra,
},
}
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
afterEach(async () => {
await unmountRoute()
localStorage.clear()
initializeLocale()
})
function slugDialog() {
return Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"]')).at(-1)!
}
function dialogText() {
return textOf(slugDialog())
}
async function openSeoPanel(setup: EditorFixture) {
await mountEditor(setup)
const panel = await openPanel()
await flushPromises()
return panel
}
describe('Search and sharing: French (loc.seo.noFallback)', () => {
it('shows the heading, the scope, the canonical, the alternates, the social locale and the slug', async () => {
const panel = await openSeoPanel(fixture())
const content = textOf(panel)
expect(content).toContain('Search and sharing · French')
expect(content).toContain('Applies only to the French version. Page language: fr.')
expect(content).toContain('Canonical')
expect(content).toContain(`https://journal.example.com/fr/${SLUG}`)
expect(content).toContain(
`Built from the Public URL pattern ${PATTERN}. Each language points to itself unless you set a canonical override.`,
)
expect(content).toContain('Alternate languages')
expect(content).toContain('Published versions in enabled languages only.')
expect(panel.querySelectorAll('[data-testid="seo-alternate-row"]')).toHaveLength(2)
expect(content).toContain(
'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
)
expect(content).toContain('Drafts and the disabled Spanish language are left out.')
expect(content).toContain('Social locale')
expect(content).toContain('og:locale')
expect(content).toContain('Set: fr_FR')
expect(content).toContain(
'Set by an owner for French in Journal languages. A region is never guessed from the language.',
)
expect(content).toContain('Address (slug)')
expect(content).toContain(
'Unique within Journal and French. Editing the title never changes the address.',
)
expect(content).toContain('Edit slug')
expect(content).toContain('Editing the title never changes the slug. Only Edit slug does.')
expect(content).toContain('Page title')
expect(content).toContain('Fallback: French title')
expect(content).toContain(
'No fallback. Empty. English site default not used. The tag is left out.',
)
expect(content).toContain('Image alternative text')
expect(content).toContain('Written per language.')
const address = panel.querySelector<HTMLInputElement>('input[readonly]')
expect(address?.value).toBe(SLUG)
})
it('shows Public URL not configured with no canonical, no alternates and a link to the setting', async () => {
const panel = await openSeoPanel(
fixture(
{ public_url_pattern: null },
resolved({ canonical_url: `https://journal.example.com/${SLUG}`, alternates: [] }),
),
)
const content = textOf(panel)
expect(content).toContain('Public URL not configured')
expect(content).toContain(
'Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work.',
)
expect(panel.querySelector('[data-testid="seo-canonical-card"]')).toBeNull()
expect(panel.querySelector('[data-testid="seo-alternates-card"]')).toBeNull()
await press('Open Public URL settings', panel)
await flushPromises()
expect(window.location.pathname + window.location.search).not.toBe('')
})
it('shows Social locale not set when nothing resolves', async () => {
const panel = await openSeoPanel(
fixture(
{},
resolved({
og: { ...resolved().og, locale: '' },
}),
),
)
const content = textOf(panel)
expect(content).toContain('Social locale not set')
expect(content).toContain(
'French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages.',
)
})
it('says a canonical override was set by hand', async () => {
const panel = await openSeoPanel(
fixture(
{},
resolved(
{ canonical_url: 'https://other.example/a' },
{ canonical: 'canonical_url' },
),
),
)
expect(textOf(panel)).toContain('https://other.example/a')
expect(textOf(panel)).toContain('Set by hand as a canonical override on this version.')
})
it('lists only the published alternates for a draft', async () => {
const panel = await openSeoPanel(fixture({}, resolved({ alternates: [ALTERNATES[0]] })))
expect(panel.querySelectorAll('[data-testid="seo-alternate-row"]')).toHaveLength(1)
})
it('keeps the current panel on a single-language site', async () => {
await mountEditor(
fixture({}, resolved(), {
languages: [{ lang: 'fr', versions: 1, published: 0 }],
translations: ['fr'],
}),
)
const panel = await openPanel()
const content = textOf(panel)
expect(content).not.toContain('Alternate languages')
expect(content).not.toContain('Social locale')
expect(content).not.toContain('Page language')
expect(content).toContain('Meta title')
})
})
describe('Fallback labels follow the API', () => {
it.each([
['title', 'description', 'Fallback: French title'],
['title', 'excerpt', 'Fallback: French excerpt'],
['title', 'body', 'Fallback: French body'],
['title', 'site', 'Fallback: French site description'],
['title', 'none', 'No fallback'],
])('title %s and description %s read "%s"', async (title, description, label) => {
const panel = await openSeoPanel(
fixture({}, resolved({}, { title, description, site_text_skipped: false })),
)
expect(textOf(panel)).toContain(label)
})
it('names the default language when the site text is withheld', async () => {
const panel = await openSeoPanel(
fixture({}, resolved({}, { description: 'none', site_text_skipped: true })),
)
expect(textOf(panel)).toContain('Tag left out')
expect(textOf(panel)).toContain('English site default not used.')
})
it('shows Uses site text for a version in the default language', async () => {
const panel = await openSeoPanel(
fixture({}, resolved({}, { description: 'site', site_text_skipped: false })),
)
expect(textOf(panel)).toContain('Uses site text')
expect(textOf(panel)).toContain('Fallback: French site description')
})
})
describe('Edit slug (loc.slug.published)', () => {
async function openDialog(setup: EditorFixture) {
const panel = await openSeoPanel(setup)
await press('Edit slug', panel)
return panel
}
it('warns about old links when the version is published', async () => {
await openDialog(fixture({}, resolved(), { online: { fr: true } }))
const content = dialogText()
expect(content).toContain('Edit slug for the French version')
expect(content).toContain('The address changes for French only.')
expect(content).toContain('This version is published')
expect(content).toContain(
'Links to the old address will stop working. RenewCMS does not create redirects.',
)
expect(content).toContain('Editing the title never changes the slug.')
expect(content).toContain('Cancel')
expect(content).toContain('Change address')
})
it('has no warning for a draft', async () => {
await openDialog(fixture())
expect(dialogText()).not.toContain('This version is published')
})
it('sends the slug and the revision and closes with a toast', async () => {
await openDialog(
fixture({}, resolved(), {
extra: {
'PATCH /v1/article/12': {
...articleOf({}),
slug: 'nouveau-titre',
revision: 4,
},
},
}),
)
const input = slugDialog().querySelector<HTMLInputElement>('input')!
await typeInto(input, 'nouveau-titre')
await press('Change address', slugDialog())
expect(callsTo('PATCH', '/v1/article/12').at(-1)?.body).toEqual({
slug: 'nouveau-titre',
if_revision: 3,
})
expect(document.querySelector('#edit-slug-input')).toBeNull()
expect(IDS.fr).toBe(12)
})
it.each([
[409, 'slug_taken', 'The slug "nouveau-titre" is already used on Journal.'],
[400, 'invalid_slug', 'Lowercase letters, numbers and hyphens. Unique on Journal.'],
])('keeps the dialog open on %s %s', async (status, code, message) => {
await openDialog(
fixture({}, resolved(), { extra: { 'PATCH /v1/article/12': failWith(status, code) } }),
)
const input = slugDialog().querySelector<HTMLInputElement>('input')!
await typeInto(input, 'nouveau-titre')
await press('Change address', slugDialog())
expect(dialogText()).toContain(message)
})
it('opens the conflict dialog on a revision conflict', async () => {
await openDialog(
fixture({}, resolved(), {
extra: {
'PATCH /v1/article/12': failWith(409, 'revision_conflict'),
'GET /v1/article/12?format=html': articleOf({}),
},
}),
)
const input = slugDialog().querySelector<HTMLInputElement>('input')!
await typeInto(input, 'nouveau-titre')
await press('Change address', slugDialog())
expect(dialogText()).not.toContain('Edit slug for the French version')
expect(panelRoot()).not.toBeNull()
})
})
@@ -8,11 +8,11 @@ import { useSessionStore } from '@/stores/session'
import type { ActivityItem } from '@/lib/activity'
import HomeView from '../HomeView.vue'
import { anonymous, makeItem, target } from './activityFixtures'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
import { storyEnvelope, storyListRoute } from './articleEnvelope'
import { accessFor } from '@/test/access'
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
const ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
const ARTICLES_ROUTE = storyListRoute({ size: 5, sort: 'updated_at:desc' })
type Handler = () => Response | Promise<Response>
@@ -97,7 +97,7 @@ describe('Home, owner or admin, the last five events with the actor name', () =>
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
await mountHome(role, {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Recent activity')
@@ -127,7 +127,7 @@ describe('Home, regular user, own events only and no actor name', () => {
it.each([['editor' as const]])('shows only the time for %s', async (role) => {
await mountHome(role as undefined, {
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Your recent activity')
@@ -148,7 +148,7 @@ describe('Home rows, failure and security markers', () => {
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
const marks = rows().map((row) => ({
@@ -179,7 +179,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
expect(rows()[0]!.text()).toContain('article.archive')
@@ -190,7 +190,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
it('has no button or link inside a row and keeps the route on a click', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(2)),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
const row = rows()[0]!
@@ -209,7 +209,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain(
@@ -230,7 +230,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
new Promise((done) => {
resolve = () => done(page(recentItems(5)))
}),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
expect(card().attributes('aria-busy')).toBeUndefined()
@@ -253,7 +253,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
vi.useFakeTimers()
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
await vi.advanceTimersByTimeAsync(200)
@@ -274,7 +274,7 @@ describe('Home card, error shows a retryable alert that leaves the page alone',
attempt += 1
return attempt === 1 ? (failure() as Response) : page(recentItems(2))
},
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
const alert = card().find('[role="alert"]')
@@ -299,7 +299,7 @@ describe('Home card, empty when there are no events yet', () => {
])('says so for %s', async (role, text) => {
await mountHome(role as 'owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
const empty = card().find('[data-testid="recent-activity-empty"]')
@@ -323,7 +323,7 @@ describe('Home card and article list are independent', () => {
it('shows the articles empty state and the card error together', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => response(500),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope([], { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope([], { size: 5 })),
})
expect(wrapper!.text()).toContain('Write your first article')
@@ -335,7 +335,7 @@ describe('Home layout puts Recent activity full width below Recently updated', (
it('renders the section after the recent list inside the same single column', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
const column = card().element.parentElement!
@@ -351,7 +351,7 @@ describe('Home card, the request URL', () => {
it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
@@ -363,7 +363,7 @@ describe('Home card, "View all" opens the activity page', () => {
it('navigates to /activity', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
const push = vi.spyOn(router, 'push')
@@ -380,7 +380,7 @@ describe('Home card, the French interface', () => {
setLocale('fr')
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Activité récente')
@@ -392,7 +392,7 @@ describe('Home card, the French interface', () => {
describe('Home card, not shown without activity.view', () => {
it('renders no recent activity card and makes no activity request', async () => {
await mountHome(undefined, {
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
[ARTICLES_ROUTE]: () => response(200, storyEnvelope(articles, { size: 5 })),
})
expect(card().exists()).toBe(false)
@@ -10,7 +10,7 @@ import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import HomeView from '../HomeView.vue'
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
import { storyEnvelope, storyListUrl } from './articleEnvelope'
import { accessFor } from '@/test/access'
function mockResponse(status: number, body: unknown = null) {
@@ -37,8 +37,8 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
session.session = { verified: true, username: 'marta', access: accessFor(role) }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
if (url === storyListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, storyEnvelope([], { size: 5 })))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
@@ -17,7 +17,7 @@ import {
startable,
verifying,
} from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
import { storyEnvelope, storyListUrl } from './articleEnvelope'
import { accessFor } from '@/test/access'
function mockResponse(status: number, body: unknown = null) {
@@ -37,8 +37,8 @@ async function mountHome(role: UserRole, status: UpdateStatus) {
session.session = { verified: true, username: 'marta', access: accessFor(role) }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
if (url === storyListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, storyEnvelope([], { size: 5 })))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
+96 -25
View File
@@ -6,7 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
import { storyEnvelope, storyListRoute } from './articleEnvelope'
let toastAdd: ReturnType<typeof vi.fn>
@@ -43,7 +43,7 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
})
}
const HOME_ARTICLES_ROUTE = articleListRoute({
const HOME_ARTICLES_ROUTE = storyListRoute({
size: 5,
sort: 'updated_at:desc',
})
@@ -149,7 +149,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
[HOME_ARTICLES_ROUTE]: () =>
new Promise((resolve) => {
resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
resolve(mockResponse(200, storyEnvelope(sixArticles, { size: 5 })))
}),
})
@@ -172,7 +172,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
vi.useFakeTimers()
const wrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () =>
Promise.resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 }))),
Promise.resolve(mockResponse(200, storyEnvelope(sixArticles, { size: 5 }))),
})
await vi.advanceTimersByTimeAsync(0)
@@ -186,8 +186,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
describe('Default — the list loaded with a mix of online and draft articles', () => {
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
const wrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -230,8 +229,7 @@ describe('A successful create hands off to the editor with a toast', () => {
updated_at: '2026-01-01T00:00:00Z',
}
const wrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
'POST /v1/article/?format=html': () => mockResponse(201, created),
})
@@ -267,7 +265,7 @@ describe('A successful create hands off to the editor with a toast', () => {
describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope([], { size: 5 })),
})
expect(wrapper.text()).toContain('Welcome to RenewCMS.')
@@ -311,7 +309,7 @@ describe('Retry re-issues the request and recovers into the Default state', () =
const listCall = vi
.fn()
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
.mockResolvedValueOnce(mockResponse(200, storyEnvelope(sixArticles, { size: 5 })))
const wrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: listCall,
})
@@ -330,7 +328,7 @@ describe('Retry re-issues the request and recovers into the Default state', () =
describe('The list is fetched exactly once per navigation', () => {
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
const listCall = vi.fn(() => mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
const listCall = vi.fn(() => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })))
const wrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: listCall,
})
@@ -353,8 +351,7 @@ describe('The greeting matches the hour of day', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
const wrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
})
expect(wrapper.text()).toContain(`${greeting}, marta`)
@@ -374,14 +371,13 @@ describe('The greeting is identical across every load state at a fixed hour', ()
loadingWrapper.unmount()
const defaultWrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
})
expect(defaultWrapper.text()).toContain('Good morning, marta')
defaultWrapper.unmount()
const emptyWrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope([], { size: 5 })),
})
expect(emptyWrapper.text()).toContain('Good morning, marta')
emptyWrapper.unmount()
@@ -408,8 +404,7 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready'
global.fetch = fetchRouter({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -433,8 +428,7 @@ describe('The greeting names the person by display name, else username', () => {
}
session.status = 'ready'
global.fetch = fetchRouter({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -460,8 +454,7 @@ describe('The greeting handles a missing username', () => {
session.session = { verified: true }
session.status = 'ready'
global.fetch = fetchRouter({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -492,13 +485,12 @@ describe('The "Connect your website" card renders in every state', () => {
vi.useRealTimers()
const defaultWrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
})
expect(defaultWrapper.text()).toContain('Connect your website')
const emptyWrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope([], { size: 5 })),
})
expect(emptyWrapper.text()).toContain('Connect your website')
@@ -508,3 +500,82 @@ describe('The "Connect your website" card renders in every state', () => {
expect(errorWrapper.text()).toContain('Connect your website')
})
})
describe('Home on a site with several languages', () => {
const base = {
body: '',
images: [],
is_online: true,
created_at: '2026-01-01T00:00:00Z',
site_id: 1,
}
const journal = [
{
...base,
id: 10,
title: 'How we price a bathroom renovation',
lang: 'en',
updated_at: '2026-01-06T00:00:00Z',
},
{
...base,
id: 12,
title: 'Comment nous chiffrons une rénovation',
lang: 'fr',
updated_at: '2026-01-05T00:00:00Z',
},
]
const site = { id: 1, name: 'Journal', key: 'journal' }
const languages = {
default: 'en',
languages: [],
revision: '1',
needs_setup: false,
available: [],
}
function multilingualHandlers() {
return {
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(journal, { size: 5 })),
'GET /v1/site/': () => mockResponse(200, [site]),
'GET /v1/site/journal/languages': () => mockResponse(200, languages),
}
}
it('asks for articles grouped by story and shows a code only for a non-default language', async () => {
const wrapper = await mountHome(multilingualHandlers())
const rows = wrapper.findAll('[data-testid="recent-list"] li')
expect(rows).toHaveLength(2)
expect(rows[0]!.find('[data-testid="language-code"]').exists()).toBe(false)
expect(rows[1]!.find('[data-testid="language-code"]').text()).toBe('FR')
expect(HOME_ARTICLES_ROUTE).toContain('group=story')
})
it('shows French titles with the code FR to a reader of French only', async () => {
const frenchOnly = journal.filter((item) => item.lang === 'fr')
const wrapper = await mountHome({
...multilingualHandlers(),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(frenchOnly, { size: 5 })),
})
const row = wrapper.find('[data-testid="recent-list"] li')
expect(row.text()).toContain('Comment nous chiffrons une rénovation')
expect(row.find('[data-testid="language-code"]').text()).toBe('FR')
})
it('counts articles, not versions', async () => {
const wrapper = await mountHome(multilingualHandlers())
const tiles = wrapper.findAll('[data-testid="stat-tile"]')
expect(tiles[0]!.text()).toContain('2')
})
it('shows no code and makes no language request on a single-language site', async () => {
const wrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, storyEnvelope(sixArticles, { size: 5 })),
})
expect(wrapper.find('[data-testid="language-code"]').exists()).toBe(false)
})
})
@@ -382,11 +382,11 @@ describe('8d More endpoints than the cap', () => {
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
'Section Errors. Status codes and messages',
)
expect(document.body.textContent).toContain('25 results')
expect(document.body.textContent).toContain('27 results')
expect(document.body.textContent).toContain(
'18 more endpoints. Keep typing to narrow the list.',
'20 more endpoints. Keep typing to narrow the list.',
)
expect(liveRegion()).toBe('25 results, showing 7')
expect(liveRegion()).toBe('27 results, showing 7')
})
})
@@ -20,6 +20,7 @@ const SECTION_LABELS = [
'Authentication',
'Articles',
'Search and sharing',
'Languages',
'Content types',
'Entries',
'Built-in types',
@@ -151,7 +152,7 @@ describe('Default, every card collapsed', () => {
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.findAll('button')).toHaveLength(14)
expect(index.findAll('button')).toHaveLength(15)
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
const jump = wrapper.get('button[aria-label="Jump to section"]')
expect(jump.text()).toContain('Jump to')
@@ -175,7 +176,7 @@ describe('Default, every card collapsed', () => {
})
it.each([
['articles', 'Articles', '8 routes', 8],
['articles', 'Articles', '10 routes', 10],
['seo', 'Search and sharing', '2 routes', 2],
['ctypes', 'Content types', '5 routes', 5],
['entries', 'Entries', '5 routes', 5],
@@ -206,6 +207,7 @@ describe('Default, every card collapsed', () => {
'auth',
'articles',
'seo',
'languages',
'ctypes',
'entries',
'builtin',
@@ -1214,3 +1216,39 @@ describe('9j Navigation, scroll-spy on Content types', () => {
host.remove()
})
})
describe('The language requests', () => {
it('shows the four cards and the real shapes of the public routes', async () => {
const wrapper = await mountIntegration()
const languages = wrapper.get('[data-testid="integration-languages"]')
const text = languages.text()
expect(text).toContain('1. Exact lookup: site + language + slug')
expect(text).toContain('GET /v1/public/site/{key}/lang/{lang}/article/{slug}')
expect(text).toContain('2. Resolve by article group, with fallback')
expect(text).toContain(
'GET /v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default',
)
expect(text).toContain('3. Errors')
expect(text).toContain('article_not_found')
expect(text).toContain('language_not_available')
expect(text).toContain('Existing, unchanged')
expect(text).toContain('GET /v1/article/{id}')
expect(text).not.toContain('Proposed')
expect(text).not.toContain('st_01')
})
it('no longer says that a title cannot change', async () => {
const wrapper = await mountIntegration()
expect(wrapper.text()).not.toContain("Titles can't be changed after creation")
})
it('lists both public routes in the catalogue', async () => {
const wrapper = await mountIntegration()
const text = wrapper.text()
expect(text).toContain('/v1/public/site/{key}/lang/{lang}/article/{slug}')
expect(text).toContain('/v1/public/site/{key}/story/{story_id}')
})
})
@@ -660,3 +660,54 @@ describe('The delete dialog', () => {
expect(callsTo('DELETE', /\/v1\/media\/11$/)).toHaveLength(1)
})
})
describe('The detail panel groups article versions under their article', () => {
const versioned = (id: number, title: string, story: number, lang: string): MediaUsageItem => ({
...article(id, title, true),
story_id: story,
lang,
})
const LOCALIZED: MediaUsageItem[] = [
versioned(10, 'How we price a bathroom renovation', 5, 'en'),
versioned(11, 'So kalkulieren wir eine Badsanierung', 5, 'de'),
versioned(12, 'Comment nous chiffrons une rénovation', 5, 'fr'),
versioned(20, 'A guide to lime plaster', 6, 'en'),
{ ...article(30, 'Legacy note', true), story_id: 30, lang: '' },
]
it('lists a story once, names its languages and counts articles', async () => {
await mountPage({ ...library(), usage: () => LOCALIZED }, '/media', undefined, {
app: true,
})
await open('kitchen-before.webp')
const rows = Array.from(document.body.querySelectorAll('[data-testid="usage-row"]'))
expect(rows.map(textOf)).toEqual([
expect.stringContaining('How we price a bathroom renovation'),
expect.stringContaining('A guide to lime plaster'),
expect.stringContaining('Legacy note'),
])
expect(textOf(rows[0]!)).toContain('English, German, French')
expect(textOf(document.body.querySelector('[data-testid="usage-articles"]')!)).toContain(
'Used in 3 articles',
)
})
it('lists a one-language site exactly as before', async () => {
const single = [versioned(20, 'Open day', 1, 'en'), versioned(21, 'Lime', 2, 'en')]
await mountPage({ ...library(), usage: () => single }, '/media', undefined, { app: true })
await open('kitchen-before.webp')
expect(document.body.querySelector('[data-testid="usage-languages"]')).toBeNull()
expect(document.body.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(2)
})
it('keeps one row per item when the answer has no story_id', async () => {
await mountPage({ ...library(), usage: () => ARTICLES }, '/media', undefined, {
app: true,
})
await open('kitchen-before.webp')
expect(document.body.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(2)
})
})
@@ -146,7 +146,7 @@ describe('Search and sharing group', () => {
const text = sitemap.text()
expect(text).toContain('A whole number from 1. Default 1.')
expect(text).toContain('25, 50, 100 or 500. Default 100.')
expect(text).toContain('{ url, modified_at, noindex }')
expect(text).toContain('{ url, modified_at, noindex, lang }')
expect(text).toContain('X-Total-Count')
expect(text).toContain('/v1/site/renew-blog/sitemap')
expect(text).toContain(BODY_MISMATCH)
@@ -165,6 +165,7 @@ describe('Search and sharing group', () => {
'Authentication',
'Articles',
'Search and sharing',
'Languages',
'Content types',
'Entries',
'Built-in types',
@@ -112,7 +112,9 @@ describe('Existing screens are unaffected', () => {
'localization-list',
'localization-editor',
'localization-conflicts-moves',
'localization-new-article',
'localization-new-article',
'localization-metadata-api',
'localization-surfaces',
])
})
})
+183
View File
@@ -0,0 +1,183 @@
import type { Page } from 'playwright'
import { accessFor } from '../src/test/access'
import { siteLanguagesBody } from '../src/test/storyLists'
import {
articleBody,
fails,
narrow,
ok,
press,
settle,
site,
state,
type Setup,
} from './editorLocalizationStates'
import type { ApiMock, VisualState } from './states'
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
const FRENCH_SLUG = 'comment-nous-chiffrons-une-renovation'
const FRENCH_TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
const alternates = [
{
id: 10,
lang: 'en',
story_id: 5,
slug: 'how-we-price-a-bathroom-renovation',
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
},
{
id: 11,
lang: 'de',
story_id: 5,
slug: 'so-kalkulieren-wir-eine-badsanierung',
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
},
]
function resolved(locale: string, canonical = `https://journal.example.com/fr/${FRENCH_SLUG}`) {
return {
title: `${FRENCH_TITLE} | Journal`,
description: '',
canonical_url: canonical,
robots: 'index, follow',
og: {
type: 'article',
title: '',
description: '',
image: '',
image_alt: '',
url: canonical,
site_name: 'Journal',
locale,
locale_alternates: [],
},
twitter: { card: 'summary' },
image: { url: '', alt: '', source: 'none' },
authors: [],
published_at: null,
modified_at: '2026-10-14T09:12:00Z',
fallbacks: {
title: 'title',
description: 'none',
image: 'none',
canonical: 'pattern',
site_text_skipped: true,
},
json_ld: {},
lang: 'fr',
alternates: canonical.includes('/fr/') ? alternates : [],
}
}
function languages(social: boolean) {
const body = siteLanguagesBody('journal')
return {
...body,
languages: body.languages.map((row) =>
row.lang === 'fr'
? {
...row,
social_locale: social ? 'fr_FR' : null,
social_locale_state: social ? 'set' : 'not_set',
}
: row,
),
}
}
function frenchArticle(setup: Setup) {
return { ...articleBody(setup, 'fr'), slug: FRENCH_SLUG, title: FRENCH_TITLE }
}
export async function openSeoPanel(page: Page) {
if (narrow(page)) {
await page
.getByRole('button', { name: /^Details/ })
.first()
.click()
await page.getByRole('dialog').waitFor()
}
const body = page.locator('[data-testid="seo-body"]:visible')
if (!(await body.count())) {
await page.locator('[data-testid="seo-toggle"]:visible').first().click()
}
await page.locator('[data-testid="seo-edit"]:visible').first().click()
await page.locator('[data-testid="seo-language-cards"]').waitFor()
await settle(page)
}
export function seoState(
setup: Setup,
pattern: string | null,
locale: string,
social: boolean,
steps: (page: Page) => Promise<void> = openSeoPanel,
): VisualState {
const canonical = pattern
? `https://journal.example.com/fr/${FRENCH_SLUG}`
: `https://journal.example.com/${FRENCH_SLUG}`
const overrides: Record<string, ApiMock> = {
'GET /v1/site/': ok([{ ...site, public_url_pattern: pattern }]),
'GET /v1/auth/me': {
status: 200,
body: {
id: 7,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
access: accessFor('owner'),
},
},
'GET /v1/article/12': ok(frenchArticle(setup)),
'GET /v1/article/12/seo': ok(resolved(locale, canonical)),
'POST /v1/article/12/seo/check': ok({
status: 'ready',
suggestions: [],
slug: { available: true, suggestion: null },
}),
}
return state({ languages: languages(social), ...setup }, steps, overrides)
}
async function openEditSlug(page: Page) {
await openSeoPanel(page)
await press(page, 'Edit slug')
await page.getByRole('dialog').last().waitFor()
await settle(page)
}
export const metadataApiStates: Record<string, VisualState> = {
'loc.seo.noFallback': seoState({ suggestion: false }, PATTERN, 'fr_FR', true),
'loc.slug.published': seoState(
{ suggestion: false, online: { fr: true } },
PATTERN,
'fr_FR',
true,
openEditSlug,
),
'loc.url.unset': seoState({ suggestion: false }, null, 'fr_FR', true),
'loc.l10.noSocial': seoState({ suggestion: false }, PATTERN, '', false),
'loc.l10.api': {
path: '/integration',
api: {
'GET /v1/auth/me': {
status: 200,
body: {
id: 7,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
access: accessFor('owner'),
},
},
'GET /v1/image-variants': fails(500),
},
steps: async (page: Page) => {
await page.locator('[data-testid="integration-languages"]').scrollIntoViewIfNeeded()
await settle(page)
},
},
}
+1 -1
View File
@@ -20,7 +20,7 @@ export interface LocalizationScreen {
const BOARD_LINE = /^Board:\s*([^.]+?)\./m
const GROUP_HEADING = /^## (L\d+[a-z]*) (.+)$/
const STATE_ROW = /^\| (loc\.[A-Za-z0-9.]+) \| /
const SELECTOR = /LocaleFrame\{s:\{v:"([^"]+)",x:"([^"]+)"\}\}/
const SELECTOR = /LocaleFrame\{s:\{v:"([^"]+)",x:"([^"]*)"\}\}/
export function parseSizes(cell: string) {
return cell
+51
View File
@@ -0,0 +1,51 @@
import type { Page } from 'playwright'
import { openOverflow, settle, state, type Setup } from './editorLocalizationStates'
import { seoState } from './localizationMetadataStates'
import type { VisualState } from './states'
async function openDetails(page: Page) {
const width = page.viewportSize()?.width ?? 0
if (width < 1024) {
await page
.getByRole('button', { name: /^Details/ })
.first()
.click()
await page.getByRole('dialog').waitFor()
}
await page
.getByText('Public API URL', { exact: true })
.locator('visible=true')
.first()
.waitFor()
await settle(page)
}
async function openMenu(page: Page) {
await openOverflow(page)
}
async function copyApiUrl(page: Page) {
await page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: async () => undefined },
configurable: true,
})
})
await openDetails(page)
await page.getByRole('button', { name: 'Copy the public API URL' }).first().click()
await page.getByText('API URL copied', { exact: true }).waitFor()
await settle(page)
}
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
const french: Setup = { suggestion: false }
export const surfacesStates: Record<string, VisualState> = {
'loc.l12.home': { path: '/home', skip: 'clock-sensitive' },
'loc.l12.homeFr': { path: '/home', skip: 'clock-sensitive' },
'loc.l12.activity': { path: '/activity', skip: 'clock-sensitive' },
'loc.l12.menu': state(french, openMenu),
'loc.l12.apiCopy': state(french, copyApiUrl),
'loc.l12.seoPreview': seoState(french, PATTERN, 'fr_FR', true),
'loc.l12.siteFallback': seoState(french, PATTERN, 'fr_FR', true),
}
+50
View File
@@ -5,6 +5,8 @@ import { approvedExceptions } from './approvedExceptions'
import { articleListStates } from './articleListStates'
import { conflictsMovesStates, editorLocalizationStates } from './editorLocalizationStates'
import { localizationStates } from './localizationStates'
import { metadataApiStates } from './localizationMetadataStates'
import { surfacesStates } from './localizationSurfacesStates'
import { newArticleLocalizationStates } from './newArticleLocalizationStates'
import { rolesStates } from './rolesStates'
@@ -1842,6 +1844,52 @@ function mediaUsageApi(overrides: Record<string, ApiMock> = {}) {
})
}
const mediaLocalizedUsage = [
{
type: 'article',
id: 10,
title: 'How we price a bathroom renovation',
published: true,
fields: ['cover'],
story_id: 5,
lang: 'en',
},
{
type: 'article',
id: 11,
title: 'So kalkulieren wir eine Badsanierung',
published: true,
fields: ['cover'],
story_id: 5,
lang: 'de',
},
{
type: 'article',
id: 12,
title: 'Comment nous chiffrons une rénovation de salle de bain',
published: false,
fields: ['body', 'cover'],
story_id: 5,
lang: 'fr',
},
{
type: 'article',
id: 20,
title: 'A guide to lime plaster',
published: true,
fields: ['body'],
story_id: 6,
lang: 'en',
},
]
const mediaSurfaceStates: Record<string, VisualState> = {
'loc.l12.media': mediaState(
mediaApi({ 'GET /v1/media/1/usage': { status: 200, body: mediaLocalizedUsage } }),
(page) => openMediaPanel(page, 1),
),
}
const mediaDuplicateIds = [12, 5, 6]
const mediaDuplicateGroups = [mediaDuplicateIds, [7, 9]]
@@ -10352,4 +10400,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'localization-editor': editorLocalizationStates,
'localization-conflicts-moves': conflictsMovesStates,
'localization-new-article': newArticleLocalizationStates,
'localization-metadata-api': metadataApiStates,
'localization-surfaces': { ...surfacesStates, ...mediaSurfaceStates },
}