feat: SEO SPA Sites, Articles and Integration (4/4)

This commit is contained in:
Florian Sylvain committed 2026-10-18 15:51:28 +02:00
1 parent cb737b7ae3
commit 02fb8751fd
47 files changed
+3218 -227

No files matched your search

+7
View File
@@ -3,10 +3,12 @@ import { t } from '@/i18n'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import SeoStatusChip from '@/components/SeoStatusChip.vue'
import AuthorStack from '@/components/AuthorStack.vue'
import { authorsSummary } from '@/lib/articleMeta'
import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { seoChipOf } from '@/lib/articleSeoStatus'
const props = withDefaults(
defineProps<{
@@ -137,6 +139,11 @@ function onRowClick(article: Article, event: MouseEvent) {
size="sm"
:pending="pendingFor(article)"
/>
<SeoStatusChip
v-if="seoChipOf(article)"
:chip="seoChipOf(article)!"
size="sm"
/>
<span :title="article.updated_at"
>{{ t('Updated') }}
{{ formatRelativeDate(article.updated_at, now) }}</span
+2 -2
View File
@@ -34,9 +34,9 @@ const selected = computed({
icon="i-lucide-globe"
:search-input="false"
:aria-label="t('Site')"
class="w-full sm:w-37.5"
class="w-full md:w-37.5"
:ui="{
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) sm:h-8 sm:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) md:h-8 md:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
leadingIcon: 'size-3.5',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none shadow-(--shadow-popover)',
+18 -6
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
withDefaults(
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md' | 'lg'
@@ -11,6 +13,9 @@ withDefaults(
size: 'md',
},
)
const themeSize = computed(() => (props.size === 'sm' ? 'md' : props.size))
const compact = computed(() => (props.size === 'sm' ? 'h-5 px-1.5' : undefined))
</script>
<template>
@@ -19,13 +24,20 @@ withDefaults(
color="neutral"
variant="outline"
icon="i-lucide-loader-circle"
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
:size="themeSize"
:class="[size === 'lg' ? undefined : 'px-2', compact]"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
</UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
<UBadge
v-else-if="online"
color="neutral"
variant="solid"
icon="i-lucide-globe"
:size="themeSize"
:class="compact"
>
{{ t('Online') }}
</UBadge>
<UBadge
@@ -33,8 +45,8 @@ withDefaults(
color="neutral"
variant="outline"
icon="i-lucide-pencil-line"
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
:size="themeSize"
:class="[size === 'lg' ? undefined : 'px-2', compact]"
>
{{ t('Draft') }}
</UBadge>
+54 -29
View File
@@ -4,10 +4,12 @@ import { t } from '@/i18n'
import { computed, ref, watchPostEffect } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import SeoStatusChip from '@/components/SeoStatusChip.vue'
import AuthorStack from '@/components/AuthorStack.vue'
import type { Site } from '@/composables/useSites'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { seoChipOf } from '@/lib/articleSeoStatus'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
const props = withDefaults(
@@ -20,7 +22,7 @@ const props = withDefaults(
sites?: Site[]
now?: Date
}>(),
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
{ skeletonRows: 6, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
)
const emit = defineEmits<{
@@ -38,6 +40,15 @@ const SINGLE_SITE_WIDTHS =
const SEVERAL_SITES_WIDTHS =
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
const rows = computed(() =>
props.loading
? Array.from(
{ length: props.skeletonRows },
(_, index) => ({ id: -(index + 1) }) as Article,
)
: props.articles,
)
const showSite = computed(() => props.sites.length > 1)
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
@@ -212,29 +223,22 @@ function actionsFor(article: Article) {
}
function onSelect(_event: Event, row: { original: Article }) {
if (props.loading) {
return
}
router.push(editPath(row.original))
}
</script>
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div
v-for="n in skeletonRows"
:key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-6 w-15 rounded-none bg-accented" />
<USkeleton v-if="showSite" class="h-3 w-17.5 rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</div>
<div v-else ref="tableRoot" class="hidden border border-default bg-default md:block">
<div
ref="tableRoot"
:aria-busy="loading ? 'true' : undefined"
class="hidden border border-default bg-default md:block"
>
<span v-if="loading" role="status" class="sr-only">{{ t('Loading articles') }}</span>
<UTable
:data="articles"
:data="rows"
:columns="columns"
:ui="{
base: 'table-fixed',
@@ -257,6 +261,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template #title-header="{ column }">
<button
type="button"
:disabled="loading"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
@@ -273,6 +278,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template #status-header="{ column }">
<button
type="button"
:disabled="loading"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
@@ -289,6 +295,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template #created_at-header="{ column }">
<button
type="button"
:disabled="loading"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
@@ -305,6 +312,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template #updated_at-header="{ column }">
<button
type="button"
:disabled="loading"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
@@ -319,7 +327,9 @@ function onSelect(_event: Event, row: { original: Article }) {
</template>
<template #title-cell="{ row }">
<USkeleton v-if="loading" class="h-3 w-3/5 rounded-none bg-accented" />
<RouterLink
v-else
:to="editPath(row.original)"
:data-row-title="row.original.id"
class="font-medium text-highlighted"
@@ -330,7 +340,8 @@ function onSelect(_event: Event, row: { original: Article }) {
</template>
<template #authors-cell="{ row }">
<span class="flex min-w-0 items-center" :title="authorNames(row.original)">
<USkeleton v-if="loading" class="h-6 w-15 rounded-none bg-accented" />
<span v-else class="flex min-w-0 items-center" :title="authorNames(row.original)">
<AuthorStack
v-if="row.original.authors?.length"
:authors="row.original.authors"
@@ -340,32 +351,46 @@ function onSelect(_event: Event, row: { original: Article }) {
</template>
<template #site-cell="{ row }">
<span class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
<USkeleton v-if="loading" class="h-3 w-17.5 rounded-none bg-accented" />
<span v-else class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
</template>
<template #status-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
size="sm"
:pending="pendingFor(row.original)"
:aria-busy="isBusy(row.original) ? 'true' : undefined"
/>
<USkeleton v-if="loading" class="h-5 w-15 rounded-none bg-accented" />
<span v-else class="flex items-center gap-1.5">
<ArticleStatusBadge
:online="row.original.is_online"
size="md"
:pending="pendingFor(row.original)"
:aria-busy="isBusy(row.original) ? 'true' : undefined"
/>
<SeoStatusChip
v-if="seoChipOf(row.original)"
:chip="seoChipOf(row.original)!"
/>
</span>
</template>
<template #created_at-cell="{ row }">
<span class="text-muted">{{
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
<span v-else class="text-muted">{{
formatAbsoluteDateFromIso(row.original.created_at)
}}</span>
</template>
<template #updated_at-cell="{ row }">
<span class="text-muted" :title="row.original.updated_at">{{
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
<span v-else class="text-muted" :title="row.original.updated_at">{{
formatRelativeDate(row.original.updated_at, now)
}}</span>
</template>
<template #actions-cell="{ row }">
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
<UDropdownMenu
v-if="!loading"
:items="actionsFor(row.original)"
:content="{ align: 'end' }"
>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
+4 -2
View File
@@ -78,8 +78,10 @@ function focusOnOpen(event: Event) {
nextTick(() => target.focus())
}
function focusFirstInvalid() {
nextTick(() => form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus())
async function focusFirstInvalid() {
await new Promise((resolve) => setTimeout(resolve))
await nextTick()
form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus()
}
defineExpose({ focusFirstInvalid })
+20 -1
View File
@@ -6,7 +6,13 @@ import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' | 'no-site'
variant:
| 'no-articles'
| 'no-results'
| 'no-drafts'
| 'no-online'
| 'no-site'
| 'needs-attention'
query?: string
}>()
@@ -62,6 +68,19 @@ const variants = computed(() => ({
action: t('Show all sites'),
primary: false,
},
'needs-attention': {
icon: 'i-lucide-circle-check',
get title() {
return t('Nothing needs attention')
},
get description() {
return t(
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
)
},
action: t('Show all articles'),
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
get title() {
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{
count: number | null
}>()
const pressed = defineModel<boolean>({ default: false })
</script>
<template>
<UButton
role="switch"
:aria-checked="pressed"
color="neutral"
variant="outline"
icon="i-lucide-lightbulb"
data-testid="needs-attention-toggle"
class="h-11 shrink-0 justify-start gap-2 border px-2.5 text-md ring-0 md:h-8 md:text-sm"
:class="
pressed
? 'border-(--rc-sand) bg-(--rc-sand) font-medium text-(--rc-on-sand) hover:bg-(--rc-sand)'
: 'border-accented bg-default font-normal text-toned'
"
:ui="{ leadingIcon: 'size-3.5' }"
@click="pressed = !pressed"
>
{{ t('Needs attention') }}
<span v-if="count !== null" class="font-mono text-2xs font-medium">{{ count }}</span>
</UButton>
</template>
+5 -1
View File
@@ -13,9 +13,10 @@ const props = withDefaults(
phase: SeoCheckPhase
status: SeoCheckStatus | null
suggestions: SeoSuggestion[]
dismissedCount?: number
sheet?: boolean
}>(),
{ sheet: false },
{ sheet: false, dismissedCount: 0 },
)
const emit = defineEmits<{
@@ -24,6 +25,7 @@ const emit = defineEmits<{
preview: []
retry: []
go: [field: SuggestionField]
restore: []
}>()
const bodyId = useId()
@@ -93,6 +95,8 @@ const buttonClass = computed(
compact
:phase="phase"
:suggestions="suggestions"
:dismissed-count="dismissedCount"
@restore="emit('restore')"
@go="emit('go', $event)"
@more="emit('edit', 'suggestions')"
@retry="emit('retry')"
+9 -1
View File
@@ -39,7 +39,7 @@ import {
type PreviewDevice,
type SeoPreviewInput,
} from '@/lib/seoPreview'
import type { SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
import type { SeoFocus, SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
const DESKTOP_QUERY = '(min-width: 48rem)'
const PANEL_FIELDS: SuggestionField[] = [
@@ -58,6 +58,8 @@ const props = defineProps<{
site: Site | undefined
phase: SeoCheckPhase
suggestions: SeoSuggestion[]
dismissedCount: number
dismissingCode: SuggestionCode | null
slugVerdict: { available: boolean; suggestion: string | null } | null
checkedSlug: string | undefined
slugTakenOnSave: string | null
@@ -74,6 +76,8 @@ const emit = defineEmits<{
close: []
preview: []
go: [field: SuggestionField]
dismiss: [code: SuggestionCode]
restore: []
}>()
const [DefineSuggestions, Suggestions] = createReusableTemplate()
@@ -349,6 +353,10 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
<SuggestionList
:phase="phase"
:suggestions="suggestions"
:dismissed-count="dismissedCount"
:dismissing-code="dismissingCode"
@dismiss="emit('dismiss', $event)"
@restore="emit('restore')"
@go="goTo"
@retry="emit('check', true)"
/>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { SeoChip } from '@/lib/articleSeoStatus'
const props = withDefaults(
defineProps<{
chip: SeoChip
size?: 'sm' | 'md'
}>(),
{ size: 'md' },
)
const description = computed(() => {
if (props.chip.kind === 'hidden') {
return t('Hidden from search engines')
}
return props.chip.count === 1 ? t('1 suggestion') : t('{0} suggestions', [props.chip.count])
})
const appearance = computed(() =>
props.chip.kind === 'hidden'
? { icon: 'i-lucide-eye-off', class: 'border border-accented bg-transparent text-toned' }
: {
icon: 'i-lucide-lightbulb',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
)
</script>
<template>
<UBadge
role="img"
color="neutral"
variant="soft"
:icon="appearance.icon"
:title="description"
:aria-label="description"
data-testid="seo-status-chip"
:class="appearance.class"
:ui="{
base: [
size === 'md' ? 'h-5.5' : 'h-5',
'shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
],
leadingIcon: 'size-3',
}"
>
{{ chip.kind === 'hidden' ? t('Hidden') : chip.count }}
</UBadge>
</template>
+147 -2
View File
@@ -2,17 +2,25 @@
import { t } from '@/i18n'
import { computed, reactive, ref } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import DirectoryPanel from '@/components/DirectoryPanel.vue'
import MediaField from '@/components/MediaField.vue'
import SlugField from '@/components/SlugField.vue'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSites, type Site, type SitePatch, type SiteRobots } from '@/composables/useSites'
import {
useSites,
type NewSite,
type Site,
type SitePatch,
type SiteRobots,
} from '@/composables/useSites'
import { useSlugField } from '@/composables/useSlugField'
import type { MediaItem } from '@/composables/useMedia'
import {
SITE_KEY_MAX,
canonicalPreview,
characterCount,
siteUrlMessage,
siteUrlProblem,
suggestFreeSlug,
@@ -20,6 +28,14 @@ import {
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import { baseUrlProblem, nameProblem, siteKeyProblem } from '@/lib/directoryRules'
import {
DEFAULT_DESCRIPTION_MAX,
DEFAULT_TITLE_TEMPLATE,
SITE_NAME_MAX,
TITLE_TEMPLATE_MAX,
exampleTitle,
templateProblem,
} from '@/lib/siteTitleTemplate'
import type { DirectoryNotice } from '@/lib/directoryTypes'
const PREVIEW_FALLBACK_URL = 'https://journal.example.com'
@@ -47,6 +63,9 @@ const currentDefault = computed(() => props.sites.find((site) => site.is_default
const values = reactive({
name: props.site?.name ?? '',
baseUrl: props.site?.base_url ?? '',
siteName: props.site?.site_name ?? '',
template: props.site?.title_template ?? DEFAULT_TITLE_TEMPLATE,
description: props.site?.default_description ?? '',
imageId: props.site?.default_social_image_id ?? null,
robots: (props.site?.default_robots ?? 'index') as SiteRobots,
locale: props.site?.locale ?? '',
@@ -62,6 +81,7 @@ const errors = reactive({
suggestion: '',
url: '',
locale: '',
template: '',
})
const saving = ref(false)
const notice = ref<DirectoryNotice | null>(null)
@@ -72,6 +92,9 @@ function snapshot() {
values.name,
slug.value.value,
values.baseUrl,
values.siteName,
values.template,
values.description,
values.imageId,
values.robots,
values.locale,
@@ -102,12 +125,23 @@ const switchHelp = computed(() =>
currentDefault.value?.name ?? '',
]),
)
const example = computed(() => exampleTitle(values.template, values.siteName, values.name))
const descriptionCount = computed(() => characterCount(values.description))
const urlError = computed(() => errors.url)
const previewUrl = computed(() =>
values.baseUrl.trim() && !urlError.value && siteUrlProblem(values.baseUrl) === null
? canonicalPreview(values.baseUrl)
: canonicalPreview(PREVIEW_FALLBACK_URL),
)
const defaultsRows = computed(() => [
{ label: t('Title'), text: t('Article title, then the template.') },
{
label: t('Description'),
text: t("Used last, after the article's description, excerpt and body."),
},
{ label: t('Image'), text: t('Used when an article has no cover image.') },
{ label: t('Robots'), text: t('Used unless an article is hidden from search engines.') },
])
const picker = computed(() => ({
title: t('Choose an image'),
description: t('The default social image for {0}.', [values.name || t('this site')]),
@@ -190,11 +224,24 @@ function checkKeyShape() {
return errors.key === ''
}
function onTemplate(value: string) {
values.template = value
if (errors.template && templateProblem(value) === '') {
errors.template = ''
}
}
function checkTemplate() {
errors.template = templateProblem(values.template)
return errors.template === ''
}
function validate() {
const nameOk = checkName()
const keyOk = nameOk ? checkKeyShape() : true
const urlOk = checkUrl()
return nameOk && keyOk && urlOk
const templateOk = checkTemplate()
return nameOk && keyOk && urlOk && templateOk
}
function changedFields(site: Site) {
@@ -205,6 +252,15 @@ function changedFields(site: Site) {
if (values.baseUrl.trim() !== site.base_url) {
patch.base_url = values.baseUrl.trim()
}
if (values.siteName.trim() !== (site.site_name ?? '')) {
patch.site_name = values.siteName.trim()
}
if (values.template.trim() !== (site.title_template ?? DEFAULT_TITLE_TEMPLATE)) {
patch.title_template = values.template.trim()
}
if (values.description.trim() !== (site.default_description ?? '')) {
patch.default_description = values.description.trim()
}
if (values.robots !== site.default_robots) {
patch.default_robots = values.robots
}
@@ -217,6 +273,20 @@ function changedFields(site: Site) {
return patch
}
function newSeoFields() {
const fields: Pick<NewSite, 'site_name' | 'title_template' | 'default_description'> = {}
if (values.siteName.trim()) {
fields.site_name = values.siteName.trim()
}
if (values.template.trim() !== DEFAULT_TITLE_TEMPLATE) {
fields.title_template = values.template.trim()
}
if (values.description.trim()) {
fields.default_description = values.description.trim()
}
return fields
}
async function create() {
const created = await createSite({
name: values.name.trim(),
@@ -225,6 +295,7 @@ async function create() {
default_robots: values.robots,
...(values.locale.trim() ? { locale: values.locale.trim() } : {}),
...(values.imageId !== null ? { default_social_image_id: values.imageId } : {}),
...newSeoFields(),
is_default: values.makeDefault,
})
emit('saved', created)
@@ -257,6 +328,10 @@ function handleFailure(error: unknown) {
errors.url = siteUrlMessage('invalid')
return
}
if (kind === 'invalidTitleTemplate') {
errors.template = t('The template must contain %title%.')
return
}
if (kind === 'invalidLocale') {
errors.locale = t('Use a language tag like en or fr-CH.')
return
@@ -370,6 +445,69 @@ async function submit() {
:picker="picker"
/>
<DirectoryField
:label="t('Site name')"
:help="t('Shown in titles through the template, and sent as the Open Graph site name.')"
>
<UInput
:model-value="values.siteName"
:placeholder="t('e.g. Renew')"
:maxlength="SITE_NAME_MAX"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
@update:model-value="values.siteName = String($event)"
/>
</DirectoryField>
<div class="flex min-w-0 flex-col gap-1.5">
<DirectoryField
:label="t('Title template')"
:error="errors.template"
:help="t('Use %title% for the article title and %site% for the site name.')"
>
<UInput
:model-value="values.template"
:color="errors.template ? 'error' : undefined"
:maxlength="TITLE_TEMPLATE_MAX"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
@update:model-value="onTemplate(String($event))"
@blur="checkTemplate"
/>
</DirectoryField>
<div
v-if="!errors.template"
class="flex min-w-0 items-center gap-1.5 bg-elevated px-2.5 py-2"
>
<span class="shrink-0 text-xs text-muted">{{ t('Example') }}</span>
<span class="min-w-0 flex-1 truncate text-highlighted">{{ example }}</span>
</div>
</div>
<DirectoryField
:label="t('Default description')"
optional
:help="t('Used when an article has no description, excerpt or body text.')"
>
<template #hint>
<CharacterCounter
:value="descriptionCount"
:max="DEFAULT_DESCRIPTION_MAX"
variant="field"
/>
</template>
<UTextarea
:model-value="values.description"
:placeholder="t('One or two sentences about the site.')"
:maxlength="DEFAULT_DESCRIPTION_MAX"
class="w-full"
:ui="{ base: 'min-h-30 text-lg md:min-h-24 md:text-sm' }"
@update:model-value="values.description = String($event)"
/>
</DirectoryField>
<DirectoryField :label="t('Default robots policy')" :help="robotsHelp">
<URadioGroup
v-model="values.robots"
@@ -430,6 +568,13 @@ async function submit() {
t('Shown when the base URL is valid.')
}}</span>
</div>
<div class="flex flex-col gap-1.5 bg-elevated p-3">
<span class="text-xs text-muted">{{ t('How articles use these defaults') }}</span>
<div v-for="row in defaultsRows" :key="row.label" class="flex gap-2">
<span class="w-21 shrink-0 font-medium text-highlighted">{{ row.label }}</span>
<span class="text-pretty text-toned">{{ row.text }}</span>
</div>
</div>
</template>
</DirectoryPanel>
</template>
+103 -6
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
import type { SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
import type { SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
const COMPACT_LIMIT = 3
const SKELETON_WIDTHS = ['w-7/10', 'w-9/10', 'w-11/20']
@@ -14,14 +14,18 @@ const props = withDefaults(
phase: SeoCheckPhase
suggestions: SeoSuggestion[]
compact?: boolean
dismissedCount?: number
dismissingCode?: SuggestionCode | null
}>(),
{ compact: false },
{ compact: false, dismissedCount: 0, dismissingCode: null },
)
const emit = defineEmits<{
go: [field: SuggestionField]
more: []
retry: []
dismiss: [code: SuggestionCode]
restore: []
}>()
const shown = computed(() =>
@@ -29,10 +33,57 @@ const shown = computed(() =>
)
const hiddenCount = computed(() => props.suggestions.length - shown.value.length)
const checking = computed(() => isChecking(props.phase))
const dismissedText = computed(() =>
props.dismissedCount === 1
? t('1 dismissed suggestion is hidden.')
: t('{0} dismissed suggestions are hidden.', [props.dismissedCount]),
)
const root = ref<HTMLElement | null>(null)
let removedCode: SuggestionCode | null = null
let removedIndex = 0
function dismiss(code: SuggestionCode, index: number) {
removedCode = code
removedIndex = index
emit('dismiss', code)
}
async function focusAfterRemoval() {
await nextTick()
const buttons = root.value?.querySelectorAll<HTMLElement>('[data-testid="suggestion-dismiss"]')
if (buttons && buttons.length > 0) {
buttons[Math.min(removedIndex, buttons.length - 1)]?.focus()
return
}
root.value?.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')?.focus()
}
watch(
() => props.suggestions.map((suggestion) => suggestion.code),
(codes) => {
if (removedCode !== null && !codes.includes(removedCode)) {
removedCode = null
focusAfterRemoval()
}
},
{ flush: 'post' },
)
watch(
() => props.dismissingCode,
(code) => {
if (code === null && removedCode !== null) {
removedCode = null
focusAfterRemoval()
}
},
{ flush: 'post' },
)
</script>
<template>
<div
ref="root"
class="flex flex-col gap-3 bg-elevated p-3"
:aria-busy="checking ? 'true' : undefined"
data-testid="suggestion-list"
@@ -69,12 +120,24 @@ const checking = computed(() => isChecking(props.phase))
<UIcon name="i-lucide-circle-check" class="mt-0.5 size-3.5 shrink-0 text-success" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{ t('All good') }}</span>
<span class="text-muted">{{ t('Nothing to suggest for this article.') }}</span>
<span class="text-muted">{{
dismissedCount > 0 ? dismissedText : t('Nothing to suggest for this article.')
}}</span>
<UButton
v-if="dismissedCount > 0"
color="neutral"
variant="link"
class="min-h-11 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
data-testid="suggestions-restore"
@click="emit('restore')"
>
{{ t('Show dismissed') }}
</UButton>
</div>
</div>
<template v-else>
<div
v-for="suggestion in shown"
v-for="(suggestion, index) in shown"
:key="suggestion.code"
class="flex items-start gap-2"
data-testid="suggestion"
@@ -95,13 +158,47 @@ const checking = computed(() => isChecking(props.phase))
{{ suggestionLinkLabel(suggestion.field) }}
</UButton>
</div>
<UButton
v-if="!compact"
color="neutral"
variant="ghost"
icon="i-lucide-x"
:loading="dismissingCode === suggestion.code"
:disabled="dismissingCode !== null"
:aria-busy="dismissingCode === suggestion.code ? 'true' : undefined"
:aria-label="t('Dismiss suggestion: {0}', [suggestionMessage(suggestion)])"
class="size-11 shrink-0 justify-center p-0 text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:size-6"
:ui="{ leadingIcon: 'size-3.5' }"
data-testid="suggestion-dismiss"
@click="dismiss(suggestion.code, index)"
/>
</div>
<span
v-if="!compact"
class="text-xs/4.5 text-pretty text-muted"
data-testid="suggestions-note"
>{{ t('Suggestions never block saving or publishing.') }}</span
>{{
t(
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
)
}}</span
>
<div
v-if="!compact && dismissedCount > 0"
class="flex flex-col gap-0.5"
data-testid="suggestions-dismissed"
>
<span class="text-xs/4.5 text-muted">{{ dismissedText }}</span>
<UButton
color="neutral"
variant="link"
class="min-h-11 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
data-testid="suggestions-restore"
@click="emit('restore')"
>
{{ t('Show dismissed') }}
</UButton>
</div>
<UButton
v-if="hiddenCount > 0"
color="neutral"
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Article } from '@/composables/useArticles'
import { DEFAULT_ARTICLE_SORT } from '@/lib/articleListQuery'
import { useArticleRowActions } from '../useArticleRowActions'
const confirmDelete = vi.fn()
vi.mock('@/composables/useArticleActions', () => ({
useArticleActions: () => ({
confirmDelete: (...args: unknown[]) => confirmDelete(...args),
publish: vi.fn(),
deleteArticle: vi.fn(),
}),
}))
vi.mock('@/i18n/presentation', () => ({
useLocalizedToast: () => ({ add: vi.fn() }),
}))
const article = {
id: 4,
title: 'Winter checklist',
body: '',
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: '2026-09-05T09:00:00Z',
} as Article
function setup() {
return useArticleRowActions({
items: ref([article]),
total: ref(1),
counts: ref({ all: 1, online: 0, draft: 1 }),
shown: ref({
page: 1,
size: 25,
q: '',
status: '',
sort: DEFAULT_ARTICLE_SORT,
site: '',
seo: '',
}),
reload: vi.fn(),
})
}
describe('handleDelete', () => {
it('opens one confirm dialog when asked twice in a row', async () => {
confirmDelete.mockReset()
let settle: (value: boolean) => void = () => undefined
confirmDelete.mockReturnValue(new Promise<boolean>((resolve) => (settle = resolve)))
const { handleDelete } = setup()
const first = handleDelete(article)
const second = handleDelete(article)
expect(confirmDelete).toHaveBeenCalledTimes(1)
settle(false)
await Promise.all([first, second])
})
it('allows another dialog once the first one is settled', async () => {
confirmDelete.mockReset()
confirmDelete.mockResolvedValue(false)
const { handleDelete } = setup()
await handleDelete(article)
await handleDelete(article)
expect(confirmDelete).toHaveBeenCalledTimes(2)
})
})
+11 -1
View File
@@ -180,9 +180,19 @@ export function useArticleRowActions({
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
}
let deleteDialogOpen = false
async function handleDelete(article: Article) {
if (deleteDialogOpen) {
return
}
deleteDialogOpen = true
const idsAtOpen = items.value.map((existing) => existing.id)
const confirmed = await actions.confirmDelete(article, () => performDelete(article))
const confirmed = await actions
.confirmDelete(article, () => performDelete(article))
.finally(() => {
deleteDialogOpen = false
})
if (confirmed) {
focusRowOrHeading(idsAtOpen, article.id)
}
+31 -2
View File
@@ -1,7 +1,8 @@
import type { SuggestionCode } from '@/lib/seoTypes'
import type { ArticleSeo } from '@/lib/articleSeo'
import { useApi } from '@/composables/useApi'
import type { Byline } from '@/lib/articleTypes'
import type { ArticleStatus } from '@/lib/articleListQuery'
import type { ArticleStatus, SEO_FILTER } from '@/lib/articleListQuery'
export type { Byline }
@@ -14,6 +15,9 @@ export interface Article extends Partial<ArticleSeo> {
authors?: Byline[]
created_at: string
updated_at: string
seo_status?: string
seo_dismissed?: SuggestionCode[]
seo_suggestions?: number
}
export type { ArticleStatus }
@@ -39,6 +43,13 @@ export interface ListArticlesParams {
status?: ArticleStatus | ''
sort?: string
site?: string
seo?: '' | typeof SEO_FILTER
}
export interface CountNeedsAttentionParams {
q?: string
status?: ArticleStatus | ''
site?: string
}
export function useArticles() {
@@ -62,8 +73,26 @@ export function useArticles() {
if (params.site) {
query.set('site', params.site)
}
if (params.seo) {
query.set('seo', params.seo)
}
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
}
return { listArticles }
async function countNeedsAttention(params: CountNeedsAttentionParams): Promise<number> {
const query = new URLSearchParams({ seo: 'suggestions', page: '1', size: '5' })
if (params.q) {
query.set('q', params.q)
}
if (params.status) {
query.set('status', params.status)
}
if (params.site) {
query.set('site', params.site)
}
const page = await request<Pick<ArticlePage, 'total'>>(`/v1/article/?${query.toString()}`)
return page.total
}
return { listArticles, countNeedsAttention }
}
+28 -4
View File
@@ -43,11 +43,13 @@ export function useSeoCheck(options: SeoCheckOptions) {
}, SEO_CHECK_DEBOUNCE_MS)
}
async function send(body: SeoCheckBody, payload: string) {
async function send(body: SeoCheckBody, payload: string, silent: boolean) {
controller?.abort()
controller = new AbortController()
const current = ++ticket
phase.value = 'pending'
if (!silent) {
phase.value = 'pending'
}
try {
const answer = await request<SeoCheckResult>(
`/v1/article/${options.articleId}/seo/check`,
@@ -81,7 +83,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
}
}
function run(force = false) {
function run(force = false, silent = false) {
clearTimer()
const body = options.body()
const payload = JSON.stringify(body)
@@ -89,7 +91,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
return Promise.resolve()
}
lastPayload = payload
return send(body, payload)
return send(body, payload, silent)
}
function runNow() {
@@ -100,6 +102,26 @@ export function useSeoCheck(options: SeoCheckOptions) {
return run(true)
}
function runQuietly() {
return run(true, true)
}
function dropSuggestion(code: string) {
const current = result.value
if (!current) {
return
}
const remaining = current.suggestions.filter((suggestion) => suggestion.code !== code)
result.value = {
...current,
suggestions: remaining,
status:
remaining.length === 0 && current.status === 'suggestions'
? 'ready'
: current.status,
}
}
function restore() {
const payload = JSON.stringify(options.body())
const answer = answers.get(payload)
@@ -143,6 +165,8 @@ export function useSeoCheck(options: SeoCheckOptions) {
schedule,
runNow,
runFresh,
runQuietly,
dropSuggestion,
restore,
invalidate,
stop,
+6
View File
@@ -26,6 +26,9 @@ export interface NewSite {
default_robots: SiteRobots
locale?: string
default_social_image_id?: number
site_name?: string
title_template?: string
default_description?: string
is_default: boolean
}
@@ -35,6 +38,9 @@ export interface SitePatch {
default_robots?: SiteRobots
locale?: string | null
default_social_image_id?: number | null
site_name?: string
title_template?: string
default_description?: string
}
export function useSites() {
+13 -4
View File
@@ -21,7 +21,6 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
Articles: [
'GET /v1/article/',
'GET /v1/article/{id}',
'GET /v1/article/{id}/seo',
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
@@ -29,6 +28,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'DELETE /v1/article/{id}',
'POST /v1/article/{id}/seo/check',
],
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
Media: [
'GET /v1/media/',
'POST /v1/media/',
@@ -50,7 +50,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'GET /v1/auth/me',
],
Settings: ['GET /v1/settings'],
Sites: ['GET /v1/site/', 'GET /v1/site/{key}/sitemap'],
Sites: ['GET /v1/site/'],
Authors: ['GET /v1/author/'],
}
@@ -207,7 +207,16 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
}
describe('Catalogue routes', () => {
it('holds exactly the 29 key-callable routes in 6 groups, in order', () => {
it('holds exactly the 29 key-callable routes in 7 groups, in order', () => {
expect(API_GROUPS.map((group) => group.title)).toEqual([
'Articles',
'Search and sharing',
'Media',
'Users and invites',
'Settings',
'Sites',
'Authors',
])
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
@@ -242,7 +251,7 @@ describe('Catalogue routes', () => {
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(6 + 29 + 25 + 6)
expect(described).toHaveLength(7 + 29 + 25 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
+30 -20
View File
@@ -60,6 +60,7 @@ export interface ErrorRow {
status: number
meaning: string
messages: string[]
note?: string
}
export interface EndpointExamples {
@@ -596,15 +597,6 @@ export const API_GROUPS: ApiGroup[] = [
json(article()),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-seo',
'GET',
'/v1/article/{id}/seo',
'Get the resolved SEO object of an article: title, description, canonical address, robots, Open Graph, Twitter, image, authors, dates and ready JSON-LD. Drafts answer 404 unless the caller is verified.',
'Public',
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
json(resolvedSeo()),
),
endpoint(
'art-create',
'POST',
@@ -821,6 +813,33 @@ export const API_GROUPS: ApiGroup[] = [
),
],
},
{
key: 'seo',
title: 'Search and sharing',
description:
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.",
endpoints: [
endpoint(
'art-seo',
'GET',
'/v1/article/{id}/seo',
'Get the resolved SEO object of an article: title with the site template applied, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld. Drafts answer 404 to anonymous callers.',
'Public',
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
json(resolvedSeo()),
),
endpoint(
'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.',
'Public',
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
json(sitemapPage()),
{ query: SITEMAP_QUERY },
),
],
},
{
key: 'media',
title: 'Media',
@@ -1199,7 +1218,7 @@ export const API_GROUPS: ApiGroup[] = [
key: 'sites',
title: 'Sites',
description:
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.',
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
endpoints: [
endpoint(
'site-list',
@@ -1226,16 +1245,6 @@ export const API_GROUPS: ApiGroup[] = [
}),
]),
),
endpoint(
'site-sitemap',
'GET',
'/v1/site/{key}/sitemap',
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.',
'Public',
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
json(sitemapPage()),
{ query: SITEMAP_QUERY },
),
],
},
{
@@ -1407,6 +1416,7 @@ export const ERROR_ROWS: ErrorRow[] = [
status: 403,
meaning: 'Forbidden',
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED],
note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
},
{
status: 404,
+4
View File
@@ -29,6 +29,7 @@ const SOURCES = [
'lib/directory.ts',
'lib/directoryCopy.ts',
'lib/directoryRules.ts',
'lib/siteTitleTemplate.ts',
'views/AuthorsView.vue',
'views/SitesView.vue',
]
@@ -44,6 +45,9 @@ const SAME_IN_FRENCH = new Set([
'Sites',
'Guest',
'Linked',
'Description',
'Image',
'Robots',
])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
+115 -4
View File
@@ -1967,10 +1967,6 @@ export const messages = {
'Lié à votre compte. Les nouveaux articles commencent avec cet auteur.',
"You don't have an author profile yet. Create one to be credited on your own articles.":
'Vous n’avez pas encore de profil d’auteur. Créez-en un pour être crédité sur vos propres articles.',
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.':
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire et tout le monde peut lire un sitemap ; les modifications se font dans le navigateur par le propriétaire.',
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.':
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau. Le total est dans l’en-tête X-Total-Count.',
'The request cannot be processed due to a mismatch in the format of the body.':
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
site_not_found: 'Site introuvable',
@@ -2144,6 +2140,121 @@ export const messages = {
'cover image': 'image de couverture',
'site social image': 'image de partage du site',
'site address + slug': 'adresse du site + slug',
'Site name': 'Nom du site',
'Shown in titles through the template, and sent as the Open Graph site name.':
'Affiché dans les titres via le modèle, et envoyé comme nom de site Open Graph.',
'e.g. Renew': 'p. ex. Renew',
'Title template': 'Modèle de titre',
'Use %title% for the article title and %site% for the site name.':
'Utilisez %title% pour le titre de l’article et %site% pour le nom du site.',
'The template must contain %title%.': 'Le modèle doit contenir %title%.',
Example: 'Exemple',
'Default description': 'Description par défaut',
'Used when an article has no description, excerpt or body text.':
'Utilisée quand un article n’a ni description, ni extrait, ni texte.',
'One or two sentences about the site.': 'Une ou deux phrases sur le site.',
'How articles use these defaults': 'Comment les articles utilisent ces valeurs par défaut',
'Article title, then the template.': 'Titre de l’article, puis le modèle.',
Description: 'Description',
"Used last, after the article's description, excerpt and body.":
'Utilisée en dernier, après la description, l’extrait et le texte de l’article.',
'Used when an article has no cover image.':
'Utilisée quand un article n’a pas d’image de couverture.',
Robots: 'Robots',
'Used unless an article is hidden from search engines.':
'Utilisée sauf si l’article est masqué des moteurs de recherche.',
'Needs attention': 'À traiter',
Hidden: 'Masqué',
'Hidden from search engines': 'Masqué des moteurs de recherche',
'Nothing needs attention': 'Rien à traiter',
'Every article has a clean check. Suggestions are optional, so this is just a bonus.':
'Tous les articles passent la vérification. Les suggestions sont facultatives, c’est un petit plus.',
'Show all articles': 'Afficher tous les articles',
'1 dismissed suggestion is hidden.': '1 suggestion ignorée est masquée.',
'{0} dismissed suggestions are hidden.': '{0} suggestions ignorées sont masquées.',
'Show dismissed': 'Afficher les suggestions ignorées',
'Dismiss suggestion: {0}': 'Ignorer la suggestion : {0}',
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.':
'Les suggestions ne bloquent jamais l’enregistrement ni la publication. Ignorez-en une et elle reste masquée pour cet article.',
"Couldn't dismiss the suggestion": 'Impossible d’ignorer la suggestion',
'The key of a site. Only its articles are listed. An unknown key answers an empty page.':
'La clé d’un site. Seuls ses articles sont listés. Une clé inconnue renvoie une page vide.',
'ready, suggestions or hidden. Only the articles with that SEO status are listed, before paging; counts stay unfiltered. Any other value is refused.':
'ready, suggestions ou hidden. Seuls les articles ayant ce statut SEO sont listés, avant la pagination ; les compteurs restent non filtrés. Toute autre valeur est refusée.',
'Update the body of an article, move it to another site, or edit its SEO fields. Send at least one field; absent fields are untouched.':
'Modifie le corps d’un article, le déplace vers un autre site ou modifie ses champs SEO. Envoyez au moins un champ ; les champs absents ne sont pas touchés.',
'slug_taken (the X-Slug-Suggestion header holds the first free slug)':
'slug_taken (l’en-tête X-Slug-Suggestion contient le premier slug libre)',
'Up to 10 000 characters. An empty string empties the body; null is refused.':
'Jusqu’à 10 000 caractères. Une chaîne vide vide le corps ; null est refusé.',
'The id of an existing site, 1 or more. null is refused.':
'L’identifiant d’un site existant, 1 ou plus. null est refusé.',
'Lowercase letters, digits and single dashes, 1 to 100 characters, unique on the site. An empty string regenerates it from the title. null is refused.':
'Lettres minuscules, chiffres et tirets simples, de 1 à 100 caractères, unique sur le site. Une chaîne vide le régénère à partir du titre. null est refusé.',
'Up to 300 characters. An empty string clears it; null is refused.':
'Jusqu’à 300 caractères. Une chaîne vide l’efface ; null est refusé.',
'Up to 120 characters. An empty string clears it; null is refused.':
'Jusqu’à 120 caractères. Une chaîne vide l’efface ; null est refusé.',
'Up to 320 characters. An empty string clears it; null is refused.':
'Jusqu’à 320 caractères. Une chaîne vide l’efface ; null est refusé.',
'The id of an existing image, 1 or more. null removes the cover.':
'L’identifiant d’une image existante, 1 ou plus. null retire la couverture.',
'Up to 200 characters. An empty string clears it; null is refused.':
'Jusqu’à 200 caractères. Une chaîne vide l’efface ; null est refusé.',
'The id of an existing image, 1 or more. null removes the social image.':
'L’identifiant d’une image existante, 1 ou plus. null retire l’image de partage.',
'An absolute https address with a host, up to 2048 characters. An empty string clears it; null is refused.':
'Une adresse https absolue avec un hôte, jusqu’à 2048 caractères. Une chaîne vide l’efface ; null est refusé.',
'Only a flag: the article stays online. null is refused.':
'Un simple indicateur : l’article reste en ligne. null est refusé.',
'Dismissed suggestion codes: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Duplicates are collapsed; null is refused.':
'Codes des suggestions ignorées : title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Les doublons sont fusionnés ; null est refusé.',
'Check the SEO of an article without saving anything. Send {} for the saved state, or fields that replace the saved values for this check only.':
'Vérifie le SEO d’un article sans rien enregistrer. Envoyez {} pour l’état enregistré, ou des champs qui remplacent les valeurs enregistrées pour cette vérification seulement.',
'Replaces the saved body for this check only. null is refused.':
'Remplace le corps enregistré pour cette vérification seulement. null est refusé.',
'Same rules as PATCH. An empty string regenerates it from the title. null is refused.':
'Mêmes règles que PATCH. Une chaîne vide le régénère à partir du titre. null est refusé.',
'Up to 300 characters. null is refused.': 'Jusqu’à 300 caractères. null est refusé.',
'Up to 120 characters. null is refused.': 'Jusqu’à 120 caractères. null est refusé.',
'Up to 320 characters. null is refused.': 'Jusqu’à 320 caractères. null est refusé.',
'The id of an existing image. null means no cover.':
'L’identifiant d’une image existante. null signifie pas de couverture.',
'Up to 200 characters. null is refused.': 'Jusqu’à 200 caractères. null est refusé.',
'The id of an existing image. null means no social image.':
'L’identifiant d’une image existante. null signifie pas d’image de partage.',
'An absolute https address with a host, up to 2048 characters. null is refused.':
'Une adresse https absolue avec un hôte, jusqu’à 2048 caractères. null est refusé.',
'null is refused.': 'null est refusé.',
'Dismissed suggestion codes, replacing the saved list for this check only. null is refused.':
'Codes des suggestions ignorées, qui remplacent la liste enregistrée pour cette vérification seulement. null est refusé.',
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.":
'RenewCMS n’affiche pas de pages et n’ajoute pas de balises meta. Ces routes donnent à votre frontend les données SEO résolues et la liste des URL publiées.',
'Get the resolved SEO object of an article: title with the site template applied, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld. Drafts answer 404 to anonymous callers.':
'Renvoie l’objet SEO résolu d’un article : title avec le modèle du site appliqué, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks et json_ld. Les brouillons répondent 404 aux appelants anonymes.',
'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.':
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau de { url, modified_at, noindex }. Le total est dans l’en-tête X-Total-Count.',
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.':
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par le propriétaire.',
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.':
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire. Les champs SEO d’un article demandent seulement le droit de modifier l’article.',
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).':
'Le serveur attend un identifiant au format d’un entier non signé de 32 bits (uint32).',
'Article not found': 'Article introuvable',
'Choose an image to keep and at least one other image to merge into it.':
'Choisissez une image à conserver et au moins une autre image à fusionner dedans.',
'Some of the images to merge changed. Refresh and try again.':
'Certaines images à fusionner ont changé. Actualisez et réessayez.',
'List the articles, authors and sites that use an image, with the places where each one uses it.':
'Liste les articles, auteurs et sites qui utilisent une image, avec les endroits où chacun l’utilise.',
'Log in. Made for the browser. A key is ignored here, with no 403.':
'Se connecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
'Log out. Made for the browser. A key is ignored here, with no 403.':
'Se déconnecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
'Register. Made for the browser. A key is ignored here, with no 403.':
'S’inscrire. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
'Reset password. Made for the browser. A key is ignored here, with no 403.':
'Réinitialiser le mot de passe. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
} as const
export type MessageKey = keyof typeof messages
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { API_GROUPS, ERROR_ROWS, PUBLIC_AUTH_ROWS, SESSION_GROUPS } from '@/data/apiCatalogue'
import { messages } from './messages'
const SEO_ROUTES = ['/seo', '/sitemap', '/seo/check']
const SAME_IN_FRENCH = new Set(['Articles', 'Sites', 'Hello world.'])
const CODES = /^[a-z_]+(?: \(.*\))?$/
function frenchOf(text: string) {
return (messages as Record<string, string>)[text]
}
describe('Search and sharing catalogue in French', () => {
const group = API_GROUPS.find((candidate) => candidate.key === 'seo')!
const seoEndpoints = API_GROUPS.flatMap((candidate) => candidate.endpoints).filter((item) =>
SEO_ROUTES.some((suffix) => item.path.endsWith(suffix)),
)
const texts = [
group.title,
group.description,
API_GROUPS.find((candidate) => candidate.key === 'sites')!.description,
...ERROR_ROWS.flatMap((row) => (row.note ? [row.note] : [])),
...seoEndpoints.flatMap((item) => [
item.description,
...item.query.map((field) => field.rule),
...item.body.map((field) => field.rule),
...item.errors.map((error) => error.caption).filter((caption) => !CODES.test(caption)),
]),
]
it('covers the group, the note and both routes', () => {
expect(seoEndpoints).toHaveLength(3)
expect(texts.length).toBeGreaterThan(10)
})
it.each([...new Set(texts)])('"%s" has its own French entry', (text) => {
expect(frenchOf(text)).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(text)) {
expect(frenchOf(text)).not.toBe(text)
}
})
})
describe('Whole catalogue in French, API-literal errors excepted', () => {
const texts = [
...API_GROUPS.flatMap((group) => [
group.title,
group.description,
...group.endpoints.flatMap((item) => [
item.description,
...item.query.map((field) => field.rule),
...item.body.map((field) => field.rule),
]),
]),
...SESSION_GROUPS.flatMap((group) => [
group.title,
...group.rows.map((row) => row.description),
]),
...PUBLIC_AUTH_ROWS.map((row) => row.description),
...ERROR_ROWS.flatMap((row) => (row.note ? [row.note] : [])),
]
it.each([...new Set(texts)])('"%s" has its own French entry', (text) => {
expect(frenchOf(text)).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(text)) {
expect(frenchOf(text)).not.toBe(text)
}
})
})
+4
View File
@@ -9,6 +9,10 @@ import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
const SOURCES = [
'components/ApiPreviewDialog.vue',
'components/EmptyState.vue',
'components/NeedsAttentionToggle.vue',
'components/SeoStatusChip.vue',
'views/ArticleView.vue',
'components/FallbackChip.vue',
'components/SearchPreview.vue',
'components/SearchSharingSection.vue',
+28 -2
View File
@@ -10,6 +10,7 @@ describe('parseArticleQuery', () => {
status: '',
sort: '',
site: '',
seo: '',
})
})
@@ -29,6 +30,7 @@ describe('parseArticleQuery', () => {
status: 'draft',
sort: 'title:asc',
site: '',
seo: '',
})
})
@@ -67,7 +69,7 @@ describe('sort parameter', () => {
})
it('omits the default from the route and treats it as equal to no sort', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, site: '' }
const base = { page: 1, size: 25, q: '', status: '' as const, site: '', seo: '' as const }
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({
sort: 'title:asc',
@@ -98,6 +100,7 @@ describe('toRouteQuery', () => {
status: '',
sort: '',
site: '',
seo: '',
}),
).toEqual({})
})
@@ -111,6 +114,7 @@ describe('toRouteQuery', () => {
status: 'online',
sort: 'title:desc',
site: '',
seo: '',
}),
).toEqual({
page: '2',
@@ -131,6 +135,7 @@ describe('sameArticleQuery', () => {
status: '' as const,
sort: '',
site: '',
seo: '',
}
expect(sameArticleQuery(base, { ...base })).toBe(true)
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
@@ -148,7 +153,7 @@ describe('site parameter', () => {
})
it('omits an empty site and tells two queries apart by their site', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '', seo: '' as const }
expect(toRouteQuery({ ...base, site: '' })).toEqual({})
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: '' })).toBe(false)
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: 'journal' })).toBe(
@@ -156,3 +161,24 @@ describe('site parameter', () => {
)
})
})
describe('seo parameter', () => {
it('reads only the value suggestions and writes it after the site', () => {
const parsed = parseArticleQuery({ site: 'journal', seo: 'suggestions' })
expect(parsed.seo).toBe('suggestions')
expect(Object.keys(toRouteQuery(parsed))).toEqual(['site', 'seo'])
})
it.each(['maybe', '', 'READY', 'ready', 'hidden', 'suggestions,x'])(
'drops the unknown value "%s"',
(seo) => {
expect(parseArticleQuery({ seo }).seo).toBe('')
},
)
it('tells two queries apart by their seo filter', () => {
const base = parseArticleQuery({})
expect(sameArticleQuery(base, { ...base, seo: 'suggestions' })).toBe(false)
expect(sameArticleQuery(base, { ...base })).toBe(true)
})
})
+8
View File
@@ -9,6 +9,8 @@ export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
export const SEO_FILTER = 'suggestions'
const SITE_KEY_MAX = 32
const POSITIVE_INTEGER = /^[1-9]\d*$/
@@ -19,6 +21,7 @@ export interface ArticleListQuery {
status: ArticleStatus | ''
sort: string
site: string
seo: '' | typeof SEO_FILTER
}
function first(value: LocationQuery[string] | undefined) {
@@ -52,6 +55,7 @@ export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
status: status === 'online' || status === 'draft' ? status : '',
sort: validSort(first(query.sort)),
site: first(query.site).slice(0, SITE_KEY_MAX),
seo: first(query.seo) === SEO_FILTER ? SEO_FILTER : '',
}
}
@@ -75,6 +79,9 @@ export function toRouteQuery(query: ArticleListQuery) {
if (query.site) {
route.site = query.site
}
if (query.seo) {
route.seo = query.seo
}
return route
}
@@ -85,6 +92,7 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
left.q === right.q &&
left.status === right.status &&
left.site === right.site &&
left.seo === right.seo &&
effectiveSort(left.sort) === effectiveSort(right.sort)
)
}
+17
View File
@@ -0,0 +1,17 @@
export type SeoChip = { kind: 'suggestions'; count: number } | { kind: 'hidden' }
interface SeoStatusSource {
seo_status?: unknown
seo_suggestions?: unknown
}
export function seoChipOf(article: SeoStatusSource): SeoChip | null {
if (article.seo_status === 'hidden') {
return { kind: 'hidden' }
}
const count = article.seo_suggestions
if (article.seo_status === 'suggestions' && typeof count === 'number' && count > 0) {
return { kind: 'suggestions', count }
}
return null
}
+3
View File
@@ -7,6 +7,7 @@ export const SITE_CODES = {
defaultSite: 'default_site',
invalidUrl: 'invalid_site_url',
invalidLocale: 'invalid_locale',
invalidTitleTemplate: 'invalid_title_template',
} as const
export const AUTHOR_CODES = {
@@ -45,6 +46,7 @@ export type FailureKind =
| 'keyTaken'
| 'invalidSiteUrl'
| 'invalidLocale'
| 'invalidTitleTemplate'
| 'slugTaken'
| 'userLinked'
| 'bioTooLong'
@@ -73,6 +75,7 @@ export function classifyFailure(error: unknown, context: FailureContext): Failur
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
[`400 ${SITE_CODES.invalidTitleTemplate}`]: 'invalidTitleTemplate',
},
author: {
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
+20
View File
@@ -0,0 +1,20 @@
import { t } from '@/i18n'
export const SITE_NAME_MAX = 100
export const TITLE_TEMPLATE_MAX = 200
export const DEFAULT_DESCRIPTION_MAX = 320
export const DEFAULT_TITLE_TEMPLATE = '%title% | %site%'
const TITLE_TOKEN = '%title%'
const SITE_TOKEN = '%site%'
const SAMPLE_TITLE = 'How we price a bathroom renovation'
const SAMPLE_SITE_NAME = 'Renew'
export function templateProblem(template: string) {
return template.includes(TITLE_TOKEN) ? '' : t('The template must contain %title%.')
}
export function exampleTitle(template: string, siteName: string, name: string) {
const site = siteName.trim() || name.trim() || SAMPLE_SITE_NAME
return template.split(TITLE_TOKEN).join(SAMPLE_TITLE).split(SITE_TOKEN).join(site)
}
+78 -8
View File
@@ -10,6 +10,7 @@ import EmptyState from '@/components/EmptyState.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
import { useArticleRowActions } from '@/composables/useArticleRowActions'
import {
useArticles,
@@ -20,6 +21,7 @@ import {
import { useSites, type Site } from '@/composables/useSites'
import {
ARTICLE_PAGE_SIZES,
SEO_FILTER,
effectiveSort,
nextSort,
parseArticleQuery,
@@ -30,11 +32,12 @@ import {
type ArticleListQuery,
} from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const DEFAULT_SKELETON_ROWS = 5
const DEFAULT_SKELETON_ROWS = 6
const ARTICLES_PATH = '/article'
type UrlMode = 'push' | 'replace'
@@ -42,13 +45,14 @@ type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode
const route = useRoute()
const router = useRouter()
const { listArticles } = useArticles()
const { listArticles, countNeedsAttention } = useArticles()
const { listSites } = useSites()
const items = ref<Article[]>([])
const sites = ref<Site[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const needsAttentionCount = ref<number | null>(null)
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
const parsed = parseArticleQuery(query)
if (query.size !== undefined) {
@@ -71,6 +75,8 @@ const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
let countToken = 0
let countedFor: string | null = null
let syncedTarget: Record<string, string> | null = null
const searchInput = ref(shown.value.q)
@@ -135,6 +141,9 @@ const emptyVariant = computed(() => {
if (shown.value.status === 'online') {
return 'no-online' as const
}
if (shown.value.seo) {
return 'needs-attention' as const
}
if (shown.value.site) {
return 'no-site' as const
}
@@ -174,8 +183,37 @@ async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
await router[mode]({ query: target })
}
function countKey(query: ArticleListQuery) {
return JSON.stringify([query.q, query.status, query.site])
}
async function refreshNeedsAttentionCount(query: ArticleListQuery) {
const key = countKey(query)
if (query.seo || key === countedFor) {
return
}
const token = ++countToken
countedFor = key
needsAttentionCount.value = null
try {
const result = await countNeedsAttention({
q: query.q,
status: query.status,
site: query.site,
})
if (token === countToken) {
needsAttentionCount.value = result
}
} catch {
if (token === countToken) {
countedFor = null
}
}
}
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
const token = ++requestToken
refreshNeedsAttentionCount(query)
desired.value = query
loading.value = true
showSkeleton.value = false
@@ -209,6 +247,11 @@ async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notic
items.value = response.items
total.value = response.total
counts.value = response.counts
if (effective.seo) {
countToken++
countedFor = countKey(effective)
needsAttentionCount.value = response.total
}
shown.value = effective
desired.value = effective
hasLoaded.value = true
@@ -258,6 +301,14 @@ function changeSite(site: string) {
load({ ...desired.value, site, page: 1 }, 'push')
}
function changeSeo(on: boolean) {
load({ ...desired.value, seo: on ? SEO_FILTER : '', page: 1 }, 'push')
}
function clearSeoFilter() {
changeSeo(false)
}
function changeSort(field: ArticleSortField) {
load(
{
@@ -347,6 +398,10 @@ function clearSearch() {
}
function onEmptyAction() {
if (emptyVariant.value === 'needs-attention') {
clearSeoFilter()
return
}
if (emptyVariant.value === 'no-site') {
changeSite('')
return
@@ -383,7 +438,7 @@ onBeforeUnmount(() => {
</script>
<template>
<DashboardShell>
<DashboardShell compact-padding>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
@@ -407,23 +462,38 @@ onBeforeUnmount(() => {
</UButton>
</div>
<div class="flex flex-col-reverse gap-4 sm:flex-row sm:items-end sm:justify-between">
<div
class="flex flex-col-reverse gap-3 md:flex-row md:items-end md:gap-4 md:border-b md:border-default"
>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="neutral"
:content="false"
:ui="{ list: 'gap-5', trigger: 'px-0' }"
class="md:-mb-px md:flex-1"
:ui="{
list: 'gap-5 border-b-0 p-0 max-md:border-b',
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-base`,
}"
/>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:gap-4 md:pb-1.5">
<UInput
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by title')"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
class="w-full md:w-65"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: 'size-4 md:size-3.5',
}"
/>
<NeedsAttentionToggle
:model-value="desired.seo !== ''"
:count="needsAttentionCount"
class="md:-order-1"
@update:model-value="changeSeo"
/>
<ArticleSiteFilter
v-if="severalSites"
+65 -1
View File
@@ -35,7 +35,7 @@ import { readSeoSectionOpen, storeSeoSectionOpen } from '@/lib/seoSectionMemory'
import { SEO_LIMITS, charCount, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
import { hostOf, slugify } from '@/lib/directory'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import type { SeoCheckBody, SeoFocus, SuggestionField } from '@/lib/seoTypes'
import type { SeoCheckBody, SeoFocus, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
import { bodyCounts } from '@/lib/bodyCounts'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
@@ -93,6 +93,8 @@ const panelSaving = ref(false)
const slugTakenOnSave = ref<string | null>(null)
const canonicalRejected = ref(false)
const apiPreviewOpen = ref(false)
const dismissedCodes = ref<SuggestionCode[]>([])
const dismissingCode = ref<SuggestionCode | null>(null)
let panelSaveFailed = false
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
@@ -153,6 +155,61 @@ const check = useSeoCheck({
onUnauthorized: draft.mirrorToStorage,
})
function reportDismissFailure() {
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't dismiss the suggestion")
},
get description() {
return t('Try again in a moment.')
},
}))
}
async function saveDismissed(next: SuggestionCode[]) {
try {
const updated = await request<Pick<Article, 'seo_dismissed'>>(`/v1/article/${articleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seo_dismissed: next }),
})
dismissedCodes.value = updated?.seo_dismissed ?? next
return true
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
} else if (err instanceof ApiError && err.status === 401) {
draft.mirrorToStorage()
} else {
reportDismissFailure()
}
return false
}
}
async function dismissSuggestion(code: SuggestionCode) {
if (dismissingCode.value !== null) {
return
}
dismissingCode.value = code
const saved = await saveDismissed([...new Set([...dismissedCodes.value, code])])
if (saved) {
check.dropSuggestion(code)
check.invalidate()
check.runQuietly()
}
dismissingCode.value = null
}
async function restoreDismissed() {
if (await saveDismissed([])) {
check.invalidate()
check.runFresh()
}
}
function editBody(newBody: string) {
draft.edit(newBody)
check.schedule()
@@ -309,6 +366,7 @@ async function loadArticle() {
fetchSites(),
])
article.value = result
dismissedCodes.value = result.seo_dismissed ?? []
sites.value = siteList
draft.load(
result.body,
@@ -559,12 +617,14 @@ onBeforeRouteLeave(async () => {
:phase="check.phase.value"
:status="check.status.value"
:suggestions="check.suggestions.value"
:dismissed-count="dismissedCodes.length"
:sheet="sheet"
@update:remembered="rememberSeoSection"
@edit="openPanel"
@preview="apiPreviewOpen = true"
@retry="check.runFresh"
@go="goToField"
@restore="restoreDismissed"
/>
</template>
</ArticleDetails>
@@ -756,6 +816,8 @@ onBeforeRouteLeave(async () => {
:site="siteOfArticle"
:phase="check.phase.value"
:suggestions="check.suggestions.value"
:dismissed-count="dismissedCodes.length"
:dismissing-code="dismissingCode"
:slug-verdict="check.slugVerdict.value"
:checked-slug="check.checkedSlug.value"
:slug-taken-on-save="slugTakenOnSave"
@@ -769,6 +831,8 @@ onBeforeRouteLeave(async () => {
@close="closePanel"
@preview="apiPreviewOpen = true"
@go="goToField"
@dismiss="dismissSuggestion"
@restore="restoreDismissed"
/>
<ApiPreviewDialog
v-model:open="apiPreviewOpen"
+19 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import CodeSnippet from '@/components/CodeSnippet.vue'
import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -21,6 +22,7 @@ import { SECTION_TITLE } from '@/lib/apiReference'
const SPY_OFFSET_PX = 120
const route = useRoute()
const origin = computed(() => window.location.origin)
const root = ref<HTMLElement | null>(null)
@@ -151,6 +153,10 @@ let scroller: HTMLElement | null = null
onMounted(() => {
scroller = root.value ? scrollParent(root.value) : null
scroller?.addEventListener('scroll', updateActive, { passive: true })
const target = route.hash.slice(1)
if (sections.value.some((section) => section.id === target)) {
nextTick(() => goTo(target))
}
})
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
@@ -354,6 +360,12 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
<span class="text-pretty text-toned">{{
quoteMessages(row.original.messages)
}}</span>
<span
v-if="row.original.note"
class="text-pretty text-muted"
data-testid="error-note"
>{{ translate(row.original.note) }}</span
>
</div>
</div>
</template>
@@ -362,6 +374,12 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
<span class="text-pretty">{{
quoteMessages(row.original.messages)
}}</span>
<span
v-if="row.original.note"
class="text-pretty text-muted"
data-testid="error-note"
>{{ translate(row.original.note) }}</span
>
</div>
</template>
</UTable>
+10 -2
View File
@@ -7,7 +7,12 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
import {
NEEDS_ATTENTION_ROUTE,
articleEnvelope,
articleListRoute,
needsAttentionCountPage,
} from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -139,7 +144,10 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
global.fetch = fetchRouter({
[NEEDS_ATTENTION_ROUTE]: () => mockResponse(200, needsAttentionCountPage()),
...handlers,
}) as unknown as typeof fetch
await router.push('/article')
const wrapper = mount(ArticleView, {
attachTo: document.body,
@@ -8,7 +8,13 @@ import type { Article } from '@/composables/useArticles'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import {
articleEnvelope,
isNeedsAttentionCount,
needsAttentionCountPage,
parseListUrl,
type EnvelopeParams,
} from './articleEnvelope'
const KEY = 'renewcms-page-size-articles'
const TOTAL = 312
@@ -45,6 +51,9 @@ function installFetch() {
json: async () => oneSite,
} as unknown as Response)
}
if (method === 'GET' && isNeedsAttentionCount(url)) {
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
@@ -9,7 +9,13 @@ import type { Article, ArticlePage } from '@/composables/useArticles'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import {
articleEnvelope,
isNeedsAttentionCount,
needsAttentionCountPage,
parseListUrl,
type EnvelopeParams,
} from './articleEnvelope'
const TOTAL = 312
const PLASTER_EVERY = 20
@@ -85,6 +91,9 @@ function installFetch() {
if (method === 'GET' && url === '/v1/site/') {
return Promise.resolve(mockResponse(200, oneSite))
}
if (method === 'GET' && isNeedsAttentionCount(url)) {
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
@@ -64,7 +64,7 @@ function siteSelect() {
}
function listCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/')
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
}
async function chooseSite(label: string) {
@@ -19,6 +19,7 @@ const STUB_ORIGIN = 'https://cms.renew.example'
const SECTION_LABELS = [
'Authentication',
'Articles',
'Search and sharing',
'Media',
'Users and invites',
'Settings',
@@ -170,15 +171,17 @@ describe('Default, every card collapsed', () => {
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the cards as 9, 10, 6 and 1', async () => {
it('groups the cards as 8, 2, 10, 6 and 1', async () => {
const wrapper = await mountIntegration()
expect(
['articles', 'media', 'users', 'settings'].map(
['articles', 'seo', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([9, 10, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('9 routes')
).toEqual([8, 2, 10, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('8 routes')
expect(section(wrapper, 'seo').text()).toContain('2 routes')
expect(section(wrapper, 'sites').text()).toContain('1 route')
expect(section(wrapper, 'settings').text()).toContain('1 route')
})
+442
View File
@@ -0,0 +1,442 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { setLocale } from '@/i18n'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { Article } from '@/composables/useArticles'
import {
articleOf,
callsTo,
createMetaBackend,
failWith,
mountMeta,
networkFailure,
renewSites,
textOf,
typeInto,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SEARCH_WAIT_MS = 320
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
setLocale('en')
await unmountMeta()
})
function withSeo(
id: number,
status: string | undefined,
count: number | undefined,
overrides: Partial<Article> = {},
) {
return articleOf(id, `Article ${id}`, {
is_online: id % 2 === 0,
...(status === undefined ? {} : { seo_status: status }),
...(count === undefined ? {} : { seo_suggestions: count }),
...overrides,
})
}
function boardArticles() {
return [
withSeo(1, 'ready', 0),
withSeo(2, 'suggestions', 2),
withSeo(3, 'hidden', 0),
withSeo(4, 'suggestions', 3),
withSeo(5, 'suggestions', 1),
]
}
function backendWith(articles: Article[], sites: 'one' | 'several' = 'one'): MetaBackend {
const backend = createMetaBackend({
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
})
backend.articles = articles
return backend
}
function toggle() {
return document.body.querySelector<HTMLElement>('[data-testid="needs-attention-toggle"]')!
}
function chips(root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="seo-status-chip"]'))
}
function bodyRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
}
function mainCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
}
function countCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/').filter((call) => !call.query.has('format'))
}
function currentQuery() {
return { ...router.currentRoute.value.query }
}
async function clickToggle() {
toggle().click()
await flushPromises()
}
async function search(value: string) {
await typeInto(document.body.querySelector<HTMLInputElement>('input[type="search"]')!, value)
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
await flushPromises()
}
async function clickTab(label: string) {
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
(item) => textOf(item).startsWith(label),
)!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
}
describe('SeoStatusChip: status table', () => {
it.each([
{ status: 'ready', count: 0, text: '', label: '' },
{ status: 'suggestions', count: 2, text: '2', label: '2 suggestions' },
{ status: 'suggestions', count: 1, text: '1', label: '1 suggestion' },
{ status: 'suggestions', count: 3, text: '3', label: '3 suggestions' },
{ status: 'hidden', count: 0, text: 'Hidden', label: 'Hidden from search engines' },
{ status: 'hidden', count: 2, text: 'Hidden', label: 'Hidden from search engines' },
])('$status with $count shows "$text"', async ({ status, count, text, label }) => {
const backend = backendWith([withSeo(1, status, count)])
await mountMeta('articles', '/article', { backend })
const found = chips(bodyRows()[0])
if (!label) {
expect(found).toHaveLength(0)
return
}
expect(found).toHaveLength(1)
const chip = found[0]!
expect(textOf(chip)).toBe(text)
expect(chip.getAttribute('title')).toBe(label)
expect(chip.getAttribute('aria-label')).toBe(label)
expect(chip.getAttribute('role')).toBe('img')
})
it.each([
{ item: 'without seo_status', article: withSeo(1, undefined, undefined) },
{ item: 'with suggestions and a count of 0', article: withSeo(1, 'suggestions', 0) },
{ item: 'with an unknown status', article: withSeo(1, 'surprise', 4) },
])('shows nothing for an item $item', async ({ article }) => {
await mountMeta('articles', '/article', { backend: backendWith([article]) })
expect(chips()).toHaveLength(0)
expect(bodyRows()).toHaveLength(1)
})
it('sits in the Status cell next to the badge on desktop and in the meta row on mobile', async () => {
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
const statusCell = bodyRows()[1]!.children[2] as HTMLElement
expect(chips(statusCell)).toHaveLength(1)
expect(textOf(statusCell)).toContain('Online')
expect(chips(document.body.querySelector('tbody')!)).toHaveLength(4)
await unmountMeta()
await mountMeta('articles', '/article', {
width: MOBILE_WIDTH,
backend: backendWith(boardArticles()),
})
const cards = Array.from(document.body.querySelectorAll<HTMLElement>('li'))
const second = cards.find((card) => textOf(card).includes('Article 2'))!
expect(chips(second)).toHaveLength(1)
expect(chips(second)[0]!.className).toContain('h-5')
})
it('reads the chip in French', async () => {
setLocale('fr')
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
const labels = chips().map((chip) => chip.getAttribute('aria-label'))
expect(labels).toContain('2 suggestions')
expect(labels).toContain('1 suggestion')
expect(labels).toContain('Masqué des moteurs de recherche')
expect(textOf(toggle())).toContain('À traiter')
})
it('keeps the chip when a row is published', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article', { backend })
document.body
.querySelector<HTMLElement>('button[aria-label="Actions for Article 3"]')!
.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => textOf(item) === 'Publish')!
.click()
await flushPromises()
expect(chips(bodyRows()[2])).toHaveLength(1)
})
})
describe('Needs attention toggle', () => {
it('is a switch with the board count, left of the search on desktop', async () => {
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
expect(toggle().getAttribute('role')).toBe('switch')
expect(toggle().getAttribute('aria-checked')).toBe('false')
expect(textOf(toggle())).toBe('Needs attention 3')
expect(toggle().className).toContain('md:-order-1')
expect(toggle().className).toContain('md:h-8')
})
it('is 44 px high on mobile', async () => {
await mountMeta('articles', '/article', {
width: MOBILE_WIDTH,
backend: backendWith(boardArticles()),
})
expect(toggle().className).toContain('h-11')
})
it('uses the sand fill when on', async () => {
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
await clickToggle()
expect(toggle().getAttribute('aria-checked')).toBe('true')
expect(toggle().className).toContain('bg-(--rc-sand)')
expect(toggle().className).toContain('text-(--rc-on-sand)')
})
it('turns on keeping q, status, sort, site and size, and resets the page', async () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta(
'articles',
'/article?page=1&size=50&q=Article&status=online&sort=title:asc&site=renew',
{ backend },
)
await clickToggle()
expect(currentQuery()).toEqual({
size: '50',
q: 'Article',
status: 'online',
sort: 'title:asc',
site: 'renew',
seo: 'suggestions',
})
const last = mainCalls(backend).at(-1)!
expect(last.query.get('seo')).toBe('suggestions')
expect(last.query.get('page')).toBe('1')
expect(last.query.get('size')).toBe('50')
expect(last.query.get('q')).toBe('Article')
expect(last.query.get('sort')).toBe('title:asc')
})
it('turns off, drops seo, resets the page and requests the list without seo', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
await clickToggle()
expect(currentQuery()).toEqual({ size: '5' })
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
expect(mainCalls(backend).at(-1)!.query.get('page')).toBe('1')
})
it('opens a URL with the filter already on', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=1', { backend })
expect(toggle().getAttribute('aria-checked')).toBe('true')
expect(mainCalls(backend)[0]!.query.get('seo')).toBe('suggestions')
expect(bodyRows()).toHaveLength(3)
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All 5')
})
it.each([
'?seo=maybe',
'?seo=',
'?seo=READY',
'?seo=ready',
'?seo=hidden',
'?seo=suggestions,x',
])('drops %s silently', async (query) => {
const backend = backendWith(boardArticles())
await mountMeta('articles', `/article${query}`, { backend })
expect(currentQuery()).toEqual({})
expect(toggle().getAttribute('aria-checked')).toBe('false')
expect(mainCalls(backend).every((call) => !call.query.has('seo'))).toBe(true)
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
})
it.each([
{ action: 'searches', act: () => search('Article') },
{ action: 'changes the tab', act: () => clickTab('Drafts') },
])('keeps ?seo=suggestions and resets the page when the user $action', async ({ act }) => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
await act()
expect(currentQuery().seo).toBe('suggestions')
expect(currentQuery().page).toBeUndefined()
expect(mainCalls(backend).at(-1)!.query.get('seo')).toBe('suggestions')
})
it('behaves the same on a single-site install', async () => {
const backend = backendWith(boardArticles(), 'one')
await mountMeta('articles', '/article', { backend })
expect(document.body.querySelector('button[aria-label="Site"]')).toBeNull()
await clickToggle()
expect(currentQuery()).toEqual({ seo: 'suggestions' })
expect(bodyRows()).toHaveLength(3)
})
})
describe('Needs attention count', () => {
it('reads the total of the extra request with the current q, status and site', async () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
const calls = countCalls(backend)
expect(calls).toHaveLength(1)
expect(calls[0]!.raw).toBe(
'/v1/article/?seo=suggestions&page=1&size=5&q=Article&status=online&site=renew',
)
})
it.each([
{ total: 3, label: 'Needs attention 3' },
{ total: 0, label: 'Needs attention 0' },
])('shows $label', async ({ total, label }) => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = (call) =>
call.query.has('format')
? {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, online: 0, draft: 0 },
}
: { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe(label)
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('shows no count and no notice on $failure', async ({ responder }) => {
const backend = backendWith(boardArticles())
const defaults = backend.responders
backend.responders['GET /v1/article/'] = (call) => {
if (!call.query.has('format')) {
return responder(call)
}
return defaults
? {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 5, online: 2, draft: 3 },
}
: undefined
}
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(currentQuery()).toEqual({})
})
it('refreshes when q, tab or site change and never for page, size or sort', async () => {
const backend = backendWith(
Array.from({ length: 30 }, (_, index) => withSeo(index + 1, 'suggestions', 1)),
'several',
)
await mountMeta('articles', '/article?size=5', { backend })
expect(countCalls(backend)).toHaveLength(1)
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
.find((item) => textOf(item).startsWith('Title'))!
.click()
await flushPromises()
expect(countCalls(backend)).toHaveLength(1)
await clickTab('Drafts')
expect(countCalls(backend)).toHaveLength(2)
await search('Article 1')
expect(countCalls(backend)).toHaveLength(3)
})
it('uses the main response total while the filter is on, with no extra request', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions', { backend })
expect(countCalls(backend)).toHaveLength(0)
expect(textOf(toggle())).toBe('Needs attention 3')
})
})
describe('Needs attention: empty states', () => {
it('shows Nothing needs attention and returns to the list with Show all articles', async () => {
const backend = backendWith([withSeo(1, 'ready', 0), withSeo(2, 'ready', 0)])
await mountMeta('articles', '/article?seo=suggestions&q=', { backend })
const text = textOf(document.body)
expect(text).toContain('Nothing needs attention')
expect(text).toContain(
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
)
expect(document.body.querySelector('[data-testid="paginator-summary"]')).toBeNull()
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
.find((item) => textOf(item) === 'Show all articles')!
.click()
await flushPromises()
expect(currentQuery()).toEqual({})
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
expect(bodyRows()).toHaveLength(2)
})
it.each([
{ query: 'q=tiles', title: 'No articles match "tiles"' },
{ query: 'status=draft', title: 'No drafts' },
{ query: 'status=online', title: 'Nothing is online yet' },
])('keeps the existing state for $query with the filter on', async ({ query, title }) => {
const backend = backendWith([withSeo(1, 'ready', 0)])
await mountMeta('articles', `/article?seo=suggestions&${query}`, { backend })
expect(textOf(document.body)).toContain(title)
expect(currentQuery().seo).toBe('suggestions')
})
it('prefers Nothing needs attention to the site state', async () => {
const backend = backendWith([withSeo(1, 'ready', 0)], 'several')
await mountMeta('articles', '/article?seo=suggestions&site=archive', { backend })
expect(textOf(document.body)).toContain('Nothing needs attention')
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
})
})
+284
View File
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { SuggestionCode } from '@/lib/seoTypes'
import { failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
import {
checkAnswer,
checkCalls,
mountSeo,
openPanel,
openSection,
patchBodies,
sectionBody,
sectionSummary,
seoBackend,
suggestion,
} from './seoTestKit'
const emptyRectList = () => Object.assign([], { item: () => null }) as unknown as DOMRectList
Range.prototype.getClientRects = emptyRectList
Range.prototype.getBoundingClientRect = () => new DOMRect()
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
afterEach(unmountMeta)
const TITLE_MESSAGE =
'The meta title is 74 characters. Search engines may cut it off after about 60.'
const ALL = [
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
suggestion('cover_alt', 'cover_image_alt'),
suggestion('no_h', 'body'),
]
function backendWithDismissals(saved: SuggestionCode[] = [], all = ALL) {
const backend = seoBackend({ article: { seo_dismissed: saved } })
const state = { saved }
backend.responders['POST /v1/article/12/seo/check'] = () =>
checkAnswer(all.filter((item) => !state.saved.includes(item.code)))
backend.responders['PATCH /v1/article/12'] = (call) => {
state.saved = [...new Set(call.body!.seo_dismissed as SuggestionCode[])]
return { ...backend.article, seo_dismissed: state.saved }
}
return backend
}
function dismissButtons(root: ParentNode = document.body) {
return Array.from(
root.querySelectorAll<HTMLButtonElement>('[data-testid="suggestion-dismiss"]'),
)
}
function panelRows(root: ParentNode) {
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
}
async function dismiss(index: number) {
dismissButtons()[index]!.click()
await flushPromises()
}
function toastText() {
return textOf(document.body.querySelector('[data-slot="viewport"]'))
}
describe('Dismiss x: rendering', () => {
it('draws one button per panel row and a footnote, and none in the compact list', async () => {
const backend = backendWithDismissals()
await mountSeo(backend)
const compact = await openSection()
expect(dismissButtons(compact)).toHaveLength(0)
expect(textOf(compact)).not.toContain('Dismiss one and it stays hidden')
const panel = await openPanel()
expect(dismissButtons(panel)).toHaveLength(3)
expect(textOf(panel)).toContain(
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
)
})
it('names the button with the message and orders it after the Go to link', async () => {
await mountSeo(backendWithDismissals())
const panel = await openPanel()
const first = dismissButtons(panel)[0]!
expect(first.getAttribute('aria-label')).toBe(`Dismiss suggestion: ${TITLE_MESSAGE}`)
expect(first.className).toContain('focus-visible:outline-2')
const row = panelRows(panel)[0]!
const link = Array.from(row.querySelectorAll('button')).find((button) =>
textOf(button).startsWith('Go to'),
)!
expect(link.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
})
it('is 44 px on mobile and 24 px on desktop', async () => {
await mountSeo(backendWithDismissals(), { width: MOBILE_WIDTH })
const panel = await openPanel()
expect(dismissButtons(panel)[0]!.className).toContain('size-11')
expect(dismissButtons(panel)[0]!.className).toContain('md:size-6')
})
})
describe('Dismiss x: behaviour', () => {
it('sends one PATCH with the saved list plus the code and nothing else', async () => {
const backend = backendWithDismissals(['no_au'])
await mountSeo(backend)
await openPanel()
const before = checkCalls(backend).length
await dismiss(0)
expect(patchBodies(backend)).toEqual([{ seo_dismissed: ['no_au', 'title_long'] }])
expect(checkCalls(backend)).toHaveLength(before + 1)
})
it('keeps the row and shows a busy button until the PATCH answers', async () => {
const backend = backendWithDismissals()
const hold = pending<unknown>()
const answer = backend.responders['PATCH /v1/article/12']!
backend.responders['PATCH /v1/article/12'] = async (call) => {
await hold.promise
return answer(call)
}
await mountSeo(backend)
const panel = await openPanel()
await dismiss(0)
expect(panelRows(panel)).toHaveLength(3)
expect(dismissButtons(panel)[0]!.disabled).toBe(true)
expect(dismissButtons(panel)[0]!.getAttribute('aria-busy')).toBe('true')
hold.resolve(undefined)
await flushPromises()
expect(panelRows(panel)).toHaveLength(2)
})
it('removes the row, keeps the order and updates the summary', async () => {
const backend = backendWithDismissals()
await mountSeo(backend)
const panel = await openPanel()
await dismiss(0)
const rows = panelRows(panel).map(textOf)
expect(rows).toHaveLength(2)
expect(rows[0]).toContain('The cover image has no alt text')
expect(rows[1]).toContain('The body has no headings')
})
it('shows All good with the dismissed line when the last one goes, and focuses the link', async () => {
const backend = backendWithDismissals([], [ALL[0]!])
await mountSeo(backend)
const panel = await openPanel()
await dismiss(0)
const text = textOf(panel)
expect(text).toContain('All good')
expect(text).toContain('1 dismissed suggestion is hidden.')
const restore = panel.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
expect(textOf(restore)).toBe('Show dismissed')
expect(document.activeElement).toBe(restore)
})
it.each([
{ position: 'first', index: 0, target: 0 },
{ position: 'second', index: 1, target: 1 },
{ position: 'last', index: 2, target: 1 },
])('moves focus after dismissing the $position row', async ({ index, target }) => {
const backend = backendWithDismissals()
await mountSeo(backend)
const panel = await openPanel()
await dismiss(index)
await flushPromises()
expect(document.activeElement).toBe(dismissButtons(panel)[target])
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('keeps the row and toasts on $failure', async ({ responder }) => {
const backend = backendWithDismissals()
backend.responders['PATCH /v1/article/12'] = responder
await mountSeo(backend)
const panel = await openPanel()
const before = checkCalls(backend).length
await dismiss(0)
expect(panelRows(panel)).toHaveLength(3)
expect(dismissButtons(panel)[0]!.disabled).toBe(false)
expect(toastText()).toContain("Couldn't dismiss the suggestion")
expect(toastText()).toContain('Try again in a moment.')
expect(checkCalls(backend)).toHaveLength(before)
})
it('shows the Not found state on a 404', async () => {
const backend = backendWithDismissals()
backend.responders['PATCH /v1/article/12'] = failWith(404)
await mountSeo(backend)
await openPanel()
await dismiss(0)
expect(textOf(document.body)).toContain("This article doesn't exist.")
})
it('shows nothing and sends no check on a 401', async () => {
const backend = backendWithDismissals()
backend.responders['PATCH /v1/article/12'] = failWith(401)
await mountSeo(backend)
const panel = await openPanel()
const before = checkCalls(backend).length
const toastsBefore = toastText()
await dismiss(0)
expect(panelRows(panel)).toHaveLength(3)
expect(toastText()).toBe(toastsBefore)
expect(checkCalls(backend)).toHaveLength(before)
})
it('restores every code with Show dismissed and checks again', async () => {
const backend = backendWithDismissals(['title_long', 'short'], [ALL[0]!])
await mountSeo(backend)
const compact = await openSection()
expect(textOf(compact)).toContain('2 dismissed suggestions are hidden.')
const link = compact.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
link.click()
await flushPromises()
expect(patchBodies(backend)).toEqual([{ seo_dismissed: [] }])
expect(sectionBody()!.querySelectorAll('[data-testid="suggestion"]')).toHaveLength(1)
expect(sectionSummary()).toBeNull()
})
it.each(['owner', 'admin', 'verified'] as const)('is available to the %s', async (role) => {
const backend = backendWithDismissals()
backend.me.role = role === 'verified' ? undefined : role
await mountSeo(backend)
const panel = await openPanel()
expect(dismissButtons(panel)).toHaveLength(3)
})
it('keeps Save enabled and does not touch the draft', async () => {
const backend = backendWithDismissals()
await mountSeo(backend)
await openPanel()
await dismiss(0)
expect(patchBodies(backend)).toHaveLength(1)
const save = document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
expect(save?.getAttribute('data-state') ?? 'saved').not.toBe('error')
})
})
describe('Dismiss x: French', () => {
it('reads the name, footnote and plural in French', async () => {
const { setLocale } = await import('@/i18n')
setLocale('fr')
try {
await mountSeo(backendWithDismissals(['no_au', 'short'], ALL))
const panel = await openPanel()
expect(dismissButtons(panel)[0]!.getAttribute('aria-label')).toMatch(
/^Ignorer la suggestion : /,
)
expect(textOf(panel)).toContain(
'Ignorez-en une et elle reste masquée pour cet article.',
)
expect(textOf(panel)).toContain('2 suggestions ignorées sont masquées.')
} finally {
setLocale('en')
}
})
})
@@ -0,0 +1,216 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import EndpointCard from '@/components/EndpointCard.vue'
import { API_GROUPS, ERROR_ROWS } from '@/data/apiCatalogue'
import IntegrationView from '../IntegrationView.vue'
const ORIGIN = 'https://cms.renew.example'
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
const NOTE =
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.'
const GROUP_DESCRIPTION =
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
let currentWrapper: VueWrapper | null = null
let fetchSpy: ReturnType<typeof vi.fn>
async function mountIntegration(path = '/integration') {
vi.stubGlobal('location', { ...window.location, origin: ORIGIN })
fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
Element.prototype.scrollIntoView = vi.fn()
await router.push(path)
currentWrapper = mount(IntegrationView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return currentWrapper
}
function section(wrapper: VueWrapper, id: string) {
return wrapper.get(`[data-anchor="${id}"]`)
}
function cardOf(wrapper: VueWrapper, route: string) {
const [method, path] = route.split(' ')
return wrapper
.findAllComponents(EndpointCard)
.find(
(item) =>
item.props('endpoint').method === method && item.props('endpoint').path === path,
)!
}
async function expand(wrapper: VueWrapper, route: string) {
const found = wrapper
.findAll('button[aria-expanded]')
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))!
await found.trigger('click')
await flushPromises()
return cardOf(wrapper, route)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.unstubAllGlobals()
setLocale('en')
await router.push('/login')
})
describe('Search and sharing group', () => {
it('sits directly after Articles with its description and 2 routes', async () => {
const wrapper = await mountIntegration()
const keys = API_GROUPS.map((group) => group.key)
expect(keys.indexOf('seo')).toBe(keys.indexOf('articles') + 1)
const group = section(wrapper, 'seo')
expect(group.text()).toContain('Search and sharing')
expect(group.text()).toContain(GROUP_DESCRIPTION)
expect(group.text()).toContain('2 routes')
})
it('holds both cards, Public and API key OK, collapsed, with no duplicate route', async () => {
const wrapper = await mountIntegration()
const group = section(wrapper, 'seo')
const cards = group.findAllComponents(EndpointCard)
expect(
cards.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`),
).toEqual(['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'])
for (const item of cards) {
expect(item.text()).toContain('Public')
expect(item.text()).toContain('API key OK')
expect(item.find('button[aria-expanded]').attributes('aria-expanded')).toBe('false')
}
const all = wrapper
.findAllComponents(EndpointCard)
.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`)
expect(all.filter((route) => route.endsWith('/seo'))).toHaveLength(1)
expect(all.filter((route) => route.endsWith('/sitemap'))).toHaveLength(1)
expect(
section(wrapper, 'articles')
.findAllComponents(EndpointCard)
.map((item) => item.props('endpoint').path),
).toContain('/v1/article/{id}/seo/check')
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
expect(section(wrapper, 'sites').text()).toContain('1 route')
})
it('describes the resolved SEO object and shows the real error rows', async () => {
const wrapper = await mountIntegration()
const seo = await expand(wrapper, 'GET /v1/article/{id}/seo')
const text = seo.text()
expect(text).toContain('Drafts answer 404 to anonymous callers.')
for (const name of [
'canonical_url',
'robots',
'og',
'twitter',
'image',
'authors',
'published_at',
'modified_at',
'fallbacks',
'json_ld',
]) {
expect(text).toContain(name)
}
expect(text).toContain('No request body.')
for (const tab of ['fetch', 'curl', 'Response']) {
expect(seo.findAll('[role="tab"]').map((item) => item.text())).toContain(tab)
}
expect(text).toContain('Copy')
expect(text).toContain('400')
expect(text).toContain('401')
expect(text).toContain('404')
})
it('shows the sitemap query rows, item shape, total header and real error strings', async () => {
const wrapper = await mountIntegration()
const sitemap = await expand(wrapper, 'GET /v1/site/{key}/sitemap')
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('X-Total-Count')
expect(text).toContain('/v1/site/renew-blog/sitemap')
expect(text).toContain(BODY_MISMATCH)
expect(text).toContain('site_not_found')
expect(text).not.toContain('Invalid page or size.')
})
it('lists the index in order and scrolls to the group with the deep link #seo', async () => {
const wrapper = await mountIntegration('/integration#seo')
const labels = wrapper
.get('nav[aria-label="On this page"]')
.findAll('button')
.map((button) => button.text())
expect(labels).toEqual([
'Authentication',
'Articles',
'Search and sharing',
'Media',
'Users and invites',
'Settings',
'Sites',
'Authors',
'Images and variants',
'Session only',
'Errors',
'Notes',
])
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
const current = wrapper
.get('nav[aria-label="On this page"]')
.findAll('button[aria-current]')
.map((button) => button.text())
expect(current).toEqual(['Search and sharing'])
})
it('adds the 403 note under the messages and leaves them unchanged', async () => {
const wrapper = await mountIntegration()
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
expect(forbidden.messages).toHaveLength(3)
expect(forbidden.note).toBe(NOTE)
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
expect(notes).toEqual([NOTE, NOTE])
expect(ERROR_ROWS.filter((row) => row.note)).toHaveLength(1)
})
it('makes no /v1 request when opening and expanding the new cards', async () => {
const wrapper = await mountIntegration()
await expand(wrapper, 'GET /v1/article/{id}/seo')
await expand(wrapper, 'GET /v1/site/{key}/sitemap')
expect(fetchSpy).not.toHaveBeenCalled()
})
it('reads the group, the cards and the note in French', async () => {
setLocale('fr')
const wrapper = await mountIntegration()
const group = section(wrapper, 'seo')
expect(group.text()).toContain('Recherche et partage')
expect(group.text()).toContain('RenewCMS n’affiche pas de pages')
expect(group.text()).toContain('2 routes')
expect(wrapper.text()).toContain(
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire.',
)
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
})
})
@@ -0,0 +1,342 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useApi } from '@/composables/useApi'
import {
blur,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
mountDirectory,
networkFailure,
openRowMenu,
textOf,
typeInto,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const TEMPLATE_ERROR = 'The template must contain %title%.'
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
const SITE_NAME_HELP = 'Shown in titles through the template, and sent as the Open Graph site name.'
const DESCRIPTION_HELP = 'Used when an article has no description, excerpt or body text.'
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function templateField() {
return box().querySelector<HTMLInputElement>('input[maxlength="200"]')!
}
function siteNameField() {
return field('e.g. Renew')
}
function descriptionField() {
return field('One or two sentences about the site.') as unknown as HTMLTextAreaElement
}
async function openEdit(name = 'Renew', backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
async function openAdd(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await click('Add site')
return backend
}
function submit(label = 'Save changes') {
return click(label, box())
}
describe('Sites SEO defaults: field order and copy', () => {
it('lists the fields in the board order, then the preview and the defaults block', async () => {
await openEdit()
const text = textOf(box())
const order = [
'Name',
'Key',
'Base URL',
'Default social image',
'Site name',
'Title template',
'Example',
'Default description',
'Default robots policy',
'Locale',
'Default site',
'Canonical URL preview',
'How articles use these defaults',
]
const positions = order.map((label) => text.indexOf(label))
expect(positions.every((position) => position >= 0)).toBe(true)
expect([...positions].sort((a, b) => a - b)).toEqual(positions)
})
it('edit: shows the saved values, helpers, example, counter and the defaults block', async () => {
await openEdit()
const text = textOf(box())
expect(siteNameField().value).toBe('Renew')
expect(text).toContain(SITE_NAME_HELP)
expect(text).not.toContain('Site name (optional)')
expect(templateField().value).toBe('%title% | %site%')
expect(text).toContain('Use %title% for the article title and %site% for the site name.')
expect(text).toContain('Example How we price a bathroom renovation | Renew')
expect(text).toContain('Default description (optional)')
expect(text).toContain('0 / 320')
expect(text).toContain(DESCRIPTION_HELP)
for (const copy of [
'Title Article title, then the template.',
"Description Used last, after the article's description, excerpt and body.",
'Image Used when an article has no cover image.',
'Robots Used unless an article is hidden from search engines.',
]) {
expect(text).toContain(copy)
}
expect(templateField().className).toContain('font-mono')
})
it('add: starts empty with the default template', async () => {
await openAdd()
expect(siteNameField().value).toBe('')
expect(templateField().value).toBe('%title% | %site%')
expect(descriptionField().value).toBe('')
expect(textOf(box())).toContain('How articles use these defaults')
})
})
describe.each([
['Journal', 'Journal', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
['Journal', '', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
['Journal', 'Le Journal', '%site%: %title%', 'Le Journal: How we price a bathroom renovation'],
['Journal', 'Journal', '%title%', 'How we price a bathroom renovation'],
['Journal', 'Journal', '%site% / %site%', 'Journal / Journal'],
['', '', '%title% | %site%', 'How we price a bathroom renovation | Renew'],
])(
'Sites SEO defaults: example for name "%s", site name "%s", template "%s"',
(name, siteName, template, example) => {
it('follows the typing and sends nothing', async () => {
const backend = await openAdd()
const before = backend.calls.length
await typeInto(field('e.g. Journal'), name)
await typeInto(siteNameField(), siteName)
await typeInto(templateField(), template)
expect(textOf(box())).toContain(`Example ${example}`)
expect(backend.calls).toHaveLength(before)
})
},
)
describe.each(['just text', '%site%', '%TITLE%', ''])(
'Sites SEO defaults: template "%s"',
(template) => {
it('is refused on blur and on submit, with nothing sent', async () => {
const backend = await openEdit()
await typeInto(templateField(), template)
await blur(templateField())
expect(textOf(box())).toContain(TEMPLATE_ERROR)
expect(textOf(box())).not.toContain('Example')
await typeInto(templateField(), template)
await submit()
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
expect(dialog()).not.toBeNull()
expect(document.activeElement).toBe(templateField())
})
},
)
describe('Sites SEO defaults: template errors', () => {
it('clears the error and shows the example again on a valid value', async () => {
await openEdit()
await typeInto(templateField(), 'nope')
await blur(templateField())
await typeInto(templateField(), '%title%')
expect(textOf(box())).not.toContain(TEMPLATE_ERROR)
expect(textOf(box())).toContain('Example')
})
it('shows the same message when the API answers 400 invalid_title_template', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = failWith(400, 'invalid_title_template')
await openEdit('Renew', backend)
await typeInto(templateField(), '%title% – %site%')
await submit()
expect(textOf(box())).toContain(TEMPLATE_ERROR)
expect(templateField().value).toBe('%title% – %site%')
})
})
describe('Sites SEO defaults: limits and counter', () => {
it('stops the inputs at 100, 200 and 320 characters', async () => {
await openAdd()
expect(siteNameField().getAttribute('maxlength')).toBe('100')
expect(templateField().getAttribute('maxlength')).toBe('200')
expect(descriptionField().getAttribute('maxlength')).toBe('320')
})
it.each([0, 45, 320])('counts %i characters without blocking', async (count) => {
await openAdd()
await typeInto(descriptionField(), 'a'.repeat(count))
expect(textOf(box())).toContain(`${count} / 320`)
})
})
describe('Sites SEO defaults: save payloads', () => {
it('sends only the changed template', async () => {
const backend = await openEdit()
await typeInto(templateField(), '%title% – %site%')
await submit()
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ title_template: '%title% – %site%' })
expect(dialog()).toBeNull()
})
it('sends site name, description and robots together, trimmed', async () => {
const backend = await openEdit()
await typeInto(siteNameField(), ' Renew Journal ')
await typeInto(descriptionField(), 'Notes on renewal work. ')
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
await flushPromises()
await submit()
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({
site_name: 'Renew Journal',
default_description: 'Notes on renewal work.',
default_robots: 'noindex',
})
})
it('creates a site without the three fields when untouched', async () => {
const backend = await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
await submit('Add site')
const body = callsTo(backend, 'POST', '/v1/site/')[0]!.body!
expect(body).not.toHaveProperty('site_name')
expect(body).not.toHaveProperty('title_template')
expect(body).not.toHaveProperty('default_description')
})
it('creates a site with the SEO values', async () => {
const backend = await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
await typeInto(siteNameField(), 'Renew Shop')
await typeInto(templateField(), '%title% - %site%')
await typeInto(descriptionField(), 'A shop about renewal.')
await submit('Add site')
expect(callsTo(backend, 'POST', '/v1/site/')[0]!.body).toMatchObject({
site_name: 'Renew Shop',
title_template: '%title% - %site%',
default_description: 'A shop about renewal.',
})
})
it('opens a site without the new fields on the defaults and sends nothing on save', async () => {
const backend = createBackend()
for (const site of backend.sites) {
delete site.site_name
delete site.title_template
delete site.default_description
}
await openEdit('Renew', backend)
expect(siteNameField().value).toBe('')
expect(descriptionField().value).toBe('')
expect(templateField().value).toBe('%title% | %site%')
await submit()
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
})
it.each(['site name', 'template', 'description'])(
'asks to discard a changed %s',
async (which) => {
await openEdit()
const target =
which === 'site name'
? siteNameField()
: which === 'template'
? templateField()
: descriptionField()
await typeInto(target, 'changed %title%')
await click('Cancel', box())
expect(textOf(document.body.querySelector('[role="alertdialog"]'))).toContain(
'Discard changes?',
)
},
)
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites SEO defaults: save failed with %s', (_name, responder) => {
it('keeps the panel, the notice and the three values', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = responder
await openEdit('Renew', backend)
await typeInto(siteNameField(), 'Renew Journal')
await typeInto(templateField(), '%title% – %site%')
await typeInto(descriptionField(), 'Notes.')
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(siteNameField().value).toBe('Renew Journal')
expect(templateField().value).toBe('%title% – %site%')
expect(descriptionField().value).toBe('Notes.')
expect(button('Save changes', box())?.disabled).toBe(false)
})
})
describe('Sites SEO defaults: sessions', () => {
it('shows the owner-only toast when the PATCH answers 403', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = failWith(403, 'owner_only')
await openEdit('Renew', backend)
await typeInto(templateField(), '%title% – %site%')
await submit()
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
it('shows the 403 page to an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
expect(document.body.textContent).toContain("You don't have access to Sites")
expect(document.body.textContent).not.toContain('Title template')
expect(vi.mocked(useApi)).toHaveBeenCalled()
})
})
+29 -2
View File
@@ -6,15 +6,25 @@ export interface EnvelopeParams {
q?: string
status?: ArticleStatus | ''
sort?: string
site?: string
seo?: '' | 'suggestions'
}
export const NEEDS_ATTENTION_TOTAL = 3
export const NEEDS_ATTENTION_ROUTE = 'GET /v1/article/?seo=suggestions&page=1&size=5'
function fold(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
const { page = 1, size = 25, q = '', status = '', sort = '', seo = '' } = params
const filtered = all
.filter(
(article) =>
!seo ||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
)
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || fold(article.title).includes(fold(q)))
if (sort) {
@@ -35,8 +45,17 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
}
}
export function isNeedsAttentionCount(url: string) {
const query = new URL(url, 'http://localhost').searchParams
return query.get('seo') === 'suggestions' && query.get('size') === '5' && !query.has('format')
}
export function needsAttentionCountPage(total = NEEDS_ATTENTION_TOTAL): ArticlePage {
return { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
}
export function articleListUrl(params: EnvelopeParams = {}) {
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
const query = new URLSearchParams({
format: 'html',
page: String(page),
@@ -51,6 +70,12 @@ export function articleListUrl(params: EnvelopeParams = {}) {
if (sort) {
query.set('sort', sort)
}
if (site) {
query.set('site', site)
}
if (seo) {
query.set('seo', seo)
}
return `/v1/article/?${query.toString()}`
}
@@ -66,5 +91,7 @@ export function parseListUrl(url: string): EnvelopeParams {
q: query.get('q') ?? '',
status: (query.get('status') ?? '') as ArticleStatus | '',
sort: query.get('sort') ?? '',
...(query.get('site') ? { site: query.get('site') ?? '' } : {}),
...(query.get('seo') ? { seo: 'suggestions' as const } : {}),
}
}
@@ -36,12 +36,15 @@ export function sitesFixture(): Site[] {
default_social_image_id: null,
default_robots: 'index' as const,
locale: null,
title_template: '%title% | %site%',
default_description: '',
}
return [
{
...base,
id: 1,
name: 'Renew',
site_name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
is_default: true,
@@ -51,6 +54,7 @@ export function sitesFixture(): Site[] {
...base,
id: 2,
name: 'Journal',
site_name: 'Journal',
key: 'journal',
base_url: 'https://journal.example',
is_default: false,
@@ -60,6 +64,7 @@ export function sitesFixture(): Site[] {
...base,
id: 3,
name: 'Projects',
site_name: 'Projects',
key: 'projects',
base_url: 'https://projects.example',
default_robots: 'noindex',
@@ -70,6 +75,7 @@ export function sitesFixture(): Site[] {
...base,
id: 4,
name: 'Archive',
site_name: 'Archive',
key: 'archive',
base_url: 'https://archive.example',
is_default: false,
+5
View File
@@ -174,6 +174,11 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
.filter((article) => !siteKey || article.site_id === siteId)
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || article.title.toLowerCase().includes(q))
.filter(
(article) =>
query.get('seo') !== 'suggestions' ||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
)
const online = backend.articles.filter((article) => article.is_online).length
return {
items: structuredClone(matching.slice((page - 1) * size, page * size)),
+18
View File
@@ -53,6 +53,18 @@ const extraSources: Record<string, Source[]> = {
states: ['With articles', 'Choose destination', 'No articles'],
alias: {},
},
{
board: officeBoard,
group: 'Sites: SEO defaults',
states: [
'Edit: SEO defaults',
'Add: defaults',
'Template: custom',
'Template: not valid',
'Save failed',
],
alias: {},
},
],
authors: [
{
@@ -195,6 +207,12 @@ const extraSources: Record<string, Source[]> = {
states: ['Several sites', 'Site filter open', 'No articles on a site'],
alias: {},
},
{
board: officeBoard,
group: 'Articles: search status',
states: ['Status chips', 'Needs attention on', 'Needs attention: none'],
alias: {},
},
{
board: officeBoard,
group: 'Articles: pagination',
+155 -119
View File
@@ -837,6 +837,8 @@ function articlesFixture() {
site_id: 1,
authors: boardBylines('Inès Duarte', 'Camille Roche'),
is_online: false,
seo_status: 'suggestions',
seo_suggestions: 2,
created_at: '2026-09-09T09:00:00Z',
updated_at: articlesFixtureIso(1 * DAY_MS),
},
@@ -848,6 +850,8 @@ function articlesFixture() {
site_id: 3,
authors: boardBylines('Jonas Pereira'),
is_online: true,
seo_status: 'hidden',
seo_suggestions: 0,
created_at: '2026-08-28T09:00:00Z',
updated_at: articlesFixtureIso(3 * DAY_MS),
},
@@ -865,6 +869,8 @@ function articlesFixture() {
'Léa Fontaine',
),
is_online: false,
seo_status: 'suggestions',
seo_suggestions: 3,
created_at: '2026-09-02T09:00:00Z',
updated_at: articlesFixtureIso(5 * DAY_MS),
},
@@ -906,7 +912,22 @@ function foldTitle(value: string) {
.toLowerCase()
}
function articleEnvelopeFor(all: ListedArticle[], url: URL) {
const boardNeedsAttentionCount = 3
function articleEnvelopeFor(
all: ListedArticle[],
url: URL,
needsAttention = boardNeedsAttentionCount,
) {
if (url.searchParams.get('seo') === 'suggestions' && !url.searchParams.has('format')) {
return {
items: [],
total: needsAttention,
page: 1,
size: 5,
counts: { all: 0, online: 0, draft: 0 },
}
}
const page = Number(url.searchParams.get('page') ?? 1)
const size = Number(url.searchParams.get('size') ?? 25)
const q = url.searchParams.get('q') ?? ''
@@ -927,8 +948,8 @@ function articleEnvelopeFor(all: ListedArticle[], url: URL) {
}
}
function articleListMock(all: ListedArticle[]): ApiMock {
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url) }
function articleListMock(all: ListedArticle[], needsAttention?: number): ApiMock {
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url, needsAttention) }
}
function articlesFixtureWithImages() {
@@ -4715,6 +4736,9 @@ const directorySites = [
{
id: 1,
name: 'Renew',
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: '',
key: 'renew',
base_url: 'https://renew.example',
default_social_image_id: 11,
@@ -4726,6 +4750,9 @@ const directorySites = [
{
id: 2,
name: 'Journal',
site_name: 'Journal',
title_template: '%title% | %site%',
default_description: '',
key: 'journal',
base_url: 'https://journal.renew.example',
default_social_image_id: null,
@@ -4737,6 +4764,9 @@ const directorySites = [
{
id: 3,
name: 'Projects',
site_name: 'Projects',
title_template: '%title% | %site%',
default_description: '',
key: 'projects',
base_url: 'https://projects.renew.example',
default_social_image_id: 12,
@@ -4748,6 +4778,9 @@ const directorySites = [
{
id: 4,
name: 'Archive',
site_name: 'Archive',
title_template: '%title% | %site%',
default_description: '',
key: 'archive',
base_url: 'https://archive.renew.example',
default_social_image_id: null,
@@ -4807,6 +4840,13 @@ function siteFormField(page: Page, placeholder: string) {
return page.getByRole('dialog').getByPlaceholder(placeholder)
}
const siteSeoDescription =
'Practical advice on renovating old buildings, from the people who do the work.'
function siteTemplateField(page: Page) {
return page.getByRole('dialog').locator('input[maxlength="200"]')
}
async function scrollPanelToTop(page: Page) {
await page.evaluate(() => {
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
@@ -4942,6 +4982,79 @@ const sitesStates: Record<string, VisualState> = {
await page.mouse.move(0, 0)
},
},
'Edit: SEO defaults': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
site.id === 1 ? { ...site, default_description: siteSeoDescription } : site,
),
},
}),
steps: async (page) => {
await openSiteEdit(page, 'Renew')
await page.mouse.move(0, 0)
},
},
'Add: defaults': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await siteFormField(page, 'https://blog.example.com').fill(
'https://journal.renew.example',
)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.mouse.move(0, 0)
},
},
'Template: custom': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
site.id === 2 ? { ...site, title_template: '%title% – %site%' } : site,
),
},
}),
steps: async (page) => {
await openSiteEdit(page, 'Journal')
await siteTemplateField(page).focus()
await scrollPanelToTop(page)
},
},
'Template: not valid': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteEdit(page, 'Journal')
await siteTemplateField(page).fill('%site%')
await siteTemplateField(page).blur()
await page.getByRole('dialog').getByText('The template must contain %title%.').waitFor()
await scrollPanelToTop(page)
},
},
'Save failed': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
site.id === 1 ? { ...site, site_name: 'Ren' } : site,
),
},
'PATCH /v1/site/1': { status: 500 },
}),
steps: async (page) => {
await openSiteEdit(page, 'Renew')
await siteFormField(page, 'e.g. Renew').fill('Renew')
await submitDirectoryPanel(page, 'Save changes')
await page.getByRole('dialog').getByText("Couldn't save the site").waitFor()
},
},
'With articles': {
path: '/settings/sites',
api: sitesApi(),
@@ -5694,8 +5807,6 @@ async function seoSaveFromPanel(page: Page) {
.click()
}
const dismissColumnReason =
'The board draws a dismiss column and its footnote in the suggestion list; dismissal is deferred to slice 4'
const boardOmitsMobileDialogReason =
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
@@ -5731,23 +5842,15 @@ const editorSeoStates: Record<string, VisualState> = {
true,
),
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
'Panel: defaults': withMobileException(
seoPanelState({ check: seoCheck(['cover_none']) }),
26.5,
dismissColumnReason,
),
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
'Panel: filled in': seoPanelState({ article: seoFilled }),
'Panel: outside the soft ranges': withMobileException(
seoPanelState({
article: {
seo_title: seoLongTitle,
seo_description: 'What a bathroom renovation costs.',
},
check: seoCheck(['title_long', 'desc_short']),
}),
15.5,
dismissColumnReason,
),
'Panel: outside the soft ranges': seoPanelState({
article: {
seo_title: seoLongTitle,
seo_description: 'What a bathroom renovation costs.',
},
check: seoCheck(['title_long', 'desc_short']),
}),
'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
'Panel: slug taken': seoPanelState(
{
@@ -5765,16 +5868,12 @@ const editorSeoStates: Record<string, VisualState> = {
await page.getByText('is already used on Renew.').waitFor()
},
),
'Panel: cover without alt': withMobileException(
seoPanelState({
article: seoCoverNoAlt,
check: seoCheck(['cover_alt']),
}),
17,
dismissColumnReason,
),
'Panel: choose cover': withMobileException(
seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
'Panel: cover without alt': seoPanelState({
article: seoCoverNoAlt,
check: seoCheck(['cover_alt']),
}),
'Panel: choose cover': {
...seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
await page
.getByRole('dialog')
.getByRole('button', { name: 'Choose image', exact: true })
@@ -5784,9 +5883,12 @@ const editorSeoStates: Record<string, VisualState> = {
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
await page.mouse.move(0, 0)
}),
29,
dismissColumnReason,
),
exception: {
maxPercent: { desktop: 10, mobile: 29 },
reason: 'The shared media picker sheet is taller and shows more tiles than the board on mobile (28.29% light, 24.08% dark); the picker is not changed here',
approvedBy: 'fsylvain',
},
},
'Panel: social sharing open': seoPanelState(
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
(page) => seoOpenDisclosure(page, 'Social sharing'),
@@ -5813,19 +5915,12 @@ const editorSeoStates: Record<string, VisualState> = {
async () => {},
700,
),
'Panel: mobile search preview': withMobileException(
seoPanelState(
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
async (page) => {
await page
.getByRole('dialog')
.getByRole('tab', { name: 'Mobile', exact: true })
.click()
await page.mouse.move(0, 0)
},
),
17.5,
dismissColumnReason,
'Panel: mobile search preview': seoPanelState(
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
async (page) => {
await page.getByRole('dialog').getByRole('tab', { name: 'Mobile', exact: true }).click()
await page.mouse.move(0, 0)
},
),
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
'Panel: saving': seoPanelState(
@@ -6282,9 +6377,9 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'6. Errors': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
exception: {
maxPercent: { desktop: 12.5, mobile: 19 },
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board changed (17.33 % mobile already at HEAD against the updated board)',
approvedBy: 'Florian Sylvain',
maxPercent: { desktop: 16, mobile: 22 },
reason: 'The 403 note under the Errors table has no layout in the design (design gap) and pushes the following rows down; the 409 row is new and the board already differed at HEAD',
approvedBy: 'fsylvain',
},
}),
'I1 · Many variants': integrationImagesState(integrationManyVariants),
@@ -6357,6 +6452,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
articles: {
...pagination,
'Status chips': clockSensitiveArticles('/article'),
'Needs attention on': clockSensitiveArticles('/article?seo=suggestions'),
'Needs attention: none': {
path: '/article?seo=suggestions',
api: articlesApi({ 'GET /v1/article/': articleListMock([], 0) }),
steps: async (page) => {
await page.getByText('Nothing needs attention').waitFor()
},
},
'Several sites': {
path: '/article',
now: articlesNow,
@@ -6380,11 +6484,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article',
now: articlesNow,
api: articlesApi(),
exception: {
maxPercent: 3.7,
reason: 'UTable column boundaries land ~9-12px off the board grid (110/130/140/44px), unresolved by nth-child width overrides on table-fixed',
approvedBy: 'Florian Sylvain',
},
},
'Row menu open': {
path: '/article',
@@ -6393,11 +6492,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
},
exception: {
maxPercent: 7.5,
reason: 'Same UTable column-boundary offset as Default, plus the open row menu popover rendering',
approvedBy: 'Florian Sylvain',
},
},
Loading: {
path: '/article',
@@ -6405,21 +6499,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: articlesApi({
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
}),
exception: {
maxPercent: 10.6,
reason: 'Skeleton placeholder block sizes/positions only approximate the board skeleton shape',
approvedBy: 'Florian Sylvain',
},
},
Empty: {
path: '/article',
now: articlesNow,
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
exception: {
maxPercent: 3.6,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
approvedBy: 'Florian Sylvain',
},
},
'No results': {
path: '/article',
@@ -6429,11 +6513,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.waitForTimeout(200)
},
exception: {
maxPercent: 3.3,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-results layout',
approvedBy: 'Florian Sylvain',
},
},
'No drafts': {
path: '/article',
@@ -6449,11 +6528,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await page.getByRole('tab', { name: /^Drafts/ }).focus()
await page.keyboard.press('Enter')
},
exception: {
maxPercent: 2.7,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-drafts layout',
approvedBy: 'Florian Sylvain',
},
},
'Action in progress': {
path: '/article',
@@ -6465,36 +6539,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
},
exception: {
maxPercent: 3.6,
reason: 'Same UTable column-boundary offset as Default, plus the busy badge/spinner rendering',
approvedBy: 'Florian Sylvain',
},
},
Published: {
path: '/article',
now: articlesNow,
api: articlesApi({
'PATCH /v1/article/4/publish': {
status: 200,
body: {
...articlesFixture()[3],
is_online: true,
updated_at: articlesNow,
},
},
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('Article published').first().waitFor()
},
exception: {
maxPercent: 4.6,
reason: 'Same UTable column-boundary offset as Default, plus the success toast rendering',
approvedBy: 'Florian Sylvain',
},
},
Published: clockSensitiveArticles('/article'),
'Action failed': {
path: '/article',
now: articlesNow,
@@ -6504,11 +6550,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await chooseArticleRowMenuItem(page, 'Unpublish')
await page.getByText("Couldn't unpublish").first().waitFor()
},
exception: {
maxPercent: 5.7,
reason: 'Same UTable column-boundary offset as Default, plus the error toast with Retry rendering',
approvedBy: 'Florian Sylvain',
},
},
'Row vanished (404)': {
path: '/article',
@@ -6519,11 +6560,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('That article no longer exists').first().waitFor()
},
exception: {
maxPercent: 4.3,
reason: 'Same UTable column-boundary offset as Default, plus the info toast after row removal',
approvedBy: 'Florian Sylvain',
},
},
'Delete: confirm': {
path: '/article',