feat: SEO SPA editor integration
This commit is contained in:
1 parent
eb9876fde3
commit
470584400f
62 files changed
+7471
-58
No files matched your search
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import SegmentedTabs from '@/components/SegmentedTabs.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useResolvedSeo } from '@/composables/useResolvedSeo'
|
||||
import { apiPreviewRows } from '@/lib/seoApiPreview'
|
||||
|
||||
type DialogView = 'formatted' | 'raw'
|
||||
|
||||
const SKELETON_WIDTHS = ['w-2/5', 'w-4/5', 'w-3/5', 'w-7/10', 'w-1/2', 'w-3/4']
|
||||
|
||||
const props = defineProps<{ articleId: number; isOnline: boolean }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const view = ref<DialogView>('formatted')
|
||||
const { phase, resolved, load } = useResolvedSeo(props.articleId)
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
const endpoint = computed(() => `GET /v1/article/${props.articleId}/seo`)
|
||||
const views = computed(() => [
|
||||
{ value: 'formatted' as const, label: t('Formatted') },
|
||||
{ value: 'raw' as const, label: t('Raw JSON') },
|
||||
])
|
||||
const rows = computed(() => (resolved.value ? apiPreviewRows(resolved.value) : []))
|
||||
const rawJson = computed(() => JSON.stringify(resolved.value, null, 2))
|
||||
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy JSON')))
|
||||
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
view.value = 'formatted'
|
||||
load()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function copyRaw() {
|
||||
try {
|
||||
await copy(rawJson.value)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:title="t('What the frontend gets')"
|
||||
:description="
|
||||
t(
|
||||
'The SEO data returned by {0}, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.',
|
||||
[endpoint],
|
||||
)
|
||||
"
|
||||
:close="{
|
||||
'aria-label': t('Close'),
|
||||
class: 'size-7 justify-center text-muted max-md:size-11',
|
||||
}"
|
||||
:ui="{
|
||||
overlay: 'z-60',
|
||||
content: `${SHEET_CONTENT_CLASS} z-60 sm:top-10 sm:max-w-180`,
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty max-md:pe-16',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-3" data-testid="api-preview">
|
||||
<UAlert
|
||||
v-if="!isOnline"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-info"
|
||||
:description="
|
||||
t(
|
||||
'This article is a draft. The API returns this data only to signed-in callers until it is published.',
|
||||
)
|
||||
"
|
||||
:ui="{ root: 'items-start gap-2 px-3 py-2.5', description: 'text-toned' }"
|
||||
data-testid="api-preview-draft"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="w-50">
|
||||
<SegmentedTabs v-model="view" :items="views" />
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="copyIcon"
|
||||
:disabled="phase !== 'ready'"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
data-testid="api-preview-copy"
|
||||
@click="copyRaw"
|
||||
>
|
||||
{{ copyLabel }}
|
||||
</UButton>
|
||||
<span class="sr-only" role="status" aria-live="polite">{{
|
||||
copied ? t('Copied') : ''
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="phase === 'error'"
|
||||
class="flex flex-col gap-2"
|
||||
data-testid="api-preview-error"
|
||||
>
|
||||
<UAlert
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
role="alert"
|
||||
:title="t('Couldn\'t load the SEO data')"
|
||||
:description="
|
||||
t('{0} failed. Your article is unchanged. Try again in a moment.', [
|
||||
endpoint,
|
||||
])
|
||||
"
|
||||
:actions="[
|
||||
{
|
||||
label: t('Try again'),
|
||||
icon: 'i-lucide-rotate-cw',
|
||||
color: 'neutral',
|
||||
variant: 'outline',
|
||||
class: 'h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:w-full max-md:justify-center md:h-7',
|
||||
onClick: load,
|
||||
},
|
||||
]"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="phase !== 'ready'"
|
||||
class="flex flex-col gap-3"
|
||||
aria-busy="true"
|
||||
data-testid="api-preview-loading"
|
||||
>
|
||||
<span
|
||||
v-for="width in SKELETON_WIDTHS"
|
||||
:key="width"
|
||||
class="block h-3.5 bg-accented"
|
||||
:class="width"
|
||||
/>
|
||||
</div>
|
||||
<pre
|
||||
v-else-if="view === 'raw'"
|
||||
tabindex="0"
|
||||
class="max-h-110 overflow-auto md:max-h-100 bg-elevated p-3 font-mono text-xs/4.5 text-highlighted"
|
||||
data-testid="api-preview-raw"
|
||||
>{{ rawJson }}</pre
|
||||
>
|
||||
<dl v-else class="flex flex-col" data-testid="api-preview-formatted">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.key"
|
||||
class="flex flex-col gap-0.5 border-b border-muted py-1.5 md:flex-row md:gap-3"
|
||||
>
|
||||
<dt class="shrink-0 font-mono text-xs/5 text-muted md:w-32.5">
|
||||
{{ row.key }}
|
||||
</dt>
|
||||
<dd class="m-0 flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<span class="break-all text-highlighted">{{ row.value }}</span>
|
||||
<FallbackChip v-if="row.chip" :label="row.chip" />
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="h-11 justify-center text-sm text-highlighted sm:h-8 sm:w-auto sm:px-3"
|
||||
data-testid="api-preview-close"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('Close') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -158,7 +158,7 @@ async function copyUrl() {
|
||||
:ui="{
|
||||
content:
|
||||
'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'min-h-0 gap-3 px-4 pt-2 pb-0 sm:px-4',
|
||||
header: 'min-h-13 items-center gap-3 border-b-0 px-4 pt-2 pb-0 sm:px-4',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'sr-only',
|
||||
body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pt-3 pb-4 sm:px-4',
|
||||
|
||||
@@ -124,6 +124,7 @@ const picker = computed(() => ({
|
||||
description: t('The avatar for {0}.', [values.name || t('this author')]),
|
||||
hint: t('Shown as a square. Choose an image with the face or logo near the middle.'),
|
||||
confirmLabel: t('Use image'),
|
||||
square: true,
|
||||
}))
|
||||
|
||||
function retryUsers() {
|
||||
|
||||
@@ -18,6 +18,8 @@ const props = withDefaults(
|
||||
saving?: boolean
|
||||
dirty?: boolean
|
||||
readOnly?: boolean
|
||||
wide?: boolean
|
||||
submitDisabled?: boolean
|
||||
notice?: DirectoryNotice | null
|
||||
}>(),
|
||||
{
|
||||
@@ -25,6 +27,8 @@ const props = withDefaults(
|
||||
saving: false,
|
||||
dirty: false,
|
||||
readOnly: false,
|
||||
wide: false,
|
||||
submitDisabled: false,
|
||||
notice: null,
|
||||
},
|
||||
)
|
||||
@@ -46,13 +50,15 @@ const closeButton = computed(() => ({
|
||||
color: 'neutral' as const,
|
||||
variant: 'ghost' as const,
|
||||
'aria-label': t('Close'),
|
||||
class: 'size-7 justify-center text-muted max-md:top-1.5 max-md:end-1.5 max-md:size-11',
|
||||
class: 'size-7 justify-center text-muted max-md:top-4.5 max-md:end-1.5 max-md:size-11',
|
||||
ui: { leadingIcon: 'size-4' },
|
||||
}))
|
||||
|
||||
const overlayUi = computed(() => ({
|
||||
content: isDesktop.value
|
||||
? 'w-full max-w-120'
|
||||
? props.wide
|
||||
? 'w-full max-w-240'
|
||||
: 'w-full max-w-120'
|
||||
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'h-14 min-h-14 gap-3 px-4 py-0 sm:px-4 md:px-5',
|
||||
wrapper: 'min-w-0 flex-1',
|
||||
@@ -185,7 +191,7 @@ function onOpenChange(isOpen: boolean) {
|
||||
type="submit"
|
||||
:form="formId"
|
||||
:loading="saving"
|
||||
:disabled="saving"
|
||||
:disabled="saving || submitDisabled"
|
||||
class="h-11 w-full justify-center px-4 text-sm md:h-8 md:w-auto"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex h-5 max-w-full items-center gap-1 self-start border border-dashed border-accented px-1.5 font-mono text-2xs/4 font-medium whitespace-nowrap text-toned"
|
||||
data-testid="fallback-chip"
|
||||
>
|
||||
<UIcon name="i-lucide-corner-down-right" class="size-2.5 shrink-0" />
|
||||
<span class="truncate">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -814,6 +814,7 @@ onUnmounted(() => {
|
||||
:in-article-ids="inArticleIds"
|
||||
:selected="selected"
|
||||
:plain="choose !== undefined"
|
||||
:tile-ratio="choose && !choose.square ? 'landscape' : 'square'"
|
||||
:hint="choose?.hint"
|
||||
@search="searchLibrary"
|
||||
@select="selectItem"
|
||||
|
||||
@@ -17,8 +17,9 @@ const props = withDefaults(
|
||||
avatarName?: string
|
||||
square?: boolean
|
||||
disabled?: boolean
|
||||
autofocusChoose?: boolean
|
||||
}>(),
|
||||
{ help: '', avatarName: '', square: false, disabled: false },
|
||||
{ help: '', avatarName: '', square: false, disabled: false, autofocusChoose: false },
|
||||
)
|
||||
|
||||
const imageId = defineModel<number | null>({ default: null })
|
||||
@@ -87,6 +88,7 @@ function remove() {
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
:data-autofocus="autofocusChoose ? '' : undefined"
|
||||
@click="pickerOpen = true"
|
||||
>
|
||||
{{ chooseLabel }}
|
||||
|
||||
@@ -39,6 +39,7 @@ const props = defineProps<{
|
||||
variants?: ImageVariant[]
|
||||
variantsReady?: boolean
|
||||
plain?: boolean
|
||||
tileRatio?: 'square' | 'landscape'
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
@@ -50,6 +51,10 @@ const emit = defineEmits<{
|
||||
upload: []
|
||||
}>()
|
||||
|
||||
const tileAspect = computed(() =>
|
||||
props.tileRatio === 'landscape' ? 'aspect-4/3' : 'aspect-square',
|
||||
)
|
||||
|
||||
const alt = defineModel<string>('alt', { default: '' })
|
||||
const variant = defineModel<string>('variant', { default: '' })
|
||||
|
||||
@@ -242,7 +247,7 @@ onBeforeUnmount(clearDebounce)
|
||||
data-testid="picker-skeleton-tile"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
|
||||
<USkeleton class="w-full rounded-none bg-accented" :class="tileAspect" />
|
||||
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,7 +284,8 @@ onBeforeUnmount(clearDebounce)
|
||||
<MediaThumb
|
||||
:item="item"
|
||||
size="md"
|
||||
class="aspect-square w-full border border-default"
|
||||
class="w-full border border-default"
|
||||
:class="tileAspect"
|
||||
/>
|
||||
<span
|
||||
v-if="isInArticle(item)"
|
||||
@@ -303,7 +309,10 @@ onBeforeUnmount(clearDebounce)
|
||||
data-testid="picker-skeleton-tile"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
|
||||
<USkeleton
|
||||
class="w-full rounded-none bg-accented"
|
||||
:class="tileAspect"
|
||||
/>
|
||||
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -353,7 +362,11 @@ onBeforeUnmount(clearDebounce)
|
||||
/>
|
||||
<UFormField
|
||||
:label="t('Alt text (optional)')"
|
||||
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
|
||||
:help="
|
||||
t(
|
||||
'Describes the image for people who use a screen reader, and shows if it doesn\'t load. Leave it empty if the image is only decoration.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
help: 'text-xs/5 text-muted',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import SegmentedTabs from '@/components/SegmentedTabs.vue'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { hostOf } from '@/lib/directory'
|
||||
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||
import {
|
||||
PREVIEW_LIMITS,
|
||||
siteDisplayName,
|
||||
previewDescription,
|
||||
previewDescriptionSource,
|
||||
previewTitle,
|
||||
previewTitleSource,
|
||||
titleIsCut,
|
||||
truncateText,
|
||||
type PreviewDevice,
|
||||
type SeoPreviewInput,
|
||||
} from '@/lib/seoPreview'
|
||||
|
||||
const SLUG_PATH_LIMIT = 36
|
||||
|
||||
const props = defineProps<{
|
||||
input: SeoPreviewInput
|
||||
site: Site | undefined
|
||||
slug: string
|
||||
}>()
|
||||
|
||||
const device = defineModel<PreviewDevice>('device', { default: 'desktop' })
|
||||
|
||||
const items = computed(() => [
|
||||
{ value: 'desktop' as const, label: t('Desktop') },
|
||||
{ value: 'mobile' as const, label: t('Mobile') },
|
||||
])
|
||||
const fullTitle = computed(() => previewTitle(props.input))
|
||||
const shownTitle = computed(() => truncateText(fullTitle.value, PREVIEW_LIMITS[device.value].title))
|
||||
const shownDescription = computed(() =>
|
||||
truncateText(previewDescription(props.input), PREVIEW_LIMITS[device.value].description),
|
||||
)
|
||||
const cutOff = computed(() => titleIsCut(fullTitle.value, device.value))
|
||||
const siteName = computed(() => siteDisplayName(props.site))
|
||||
const siteInitial = computed(() => [...siteName.value][0] ?? '')
|
||||
const host = computed(() => hostOf(props.site?.base_url ?? ''))
|
||||
const chips = computed(() => {
|
||||
const titleChip =
|
||||
previewTitleSource(props.input) === 'title' ? [chipLabel('article_title')] : []
|
||||
const description = descriptionChip(previewDescriptionSource(props.input))
|
||||
return [...titleChip, ...(description ? [description] : [])]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2" data-testid="search-preview">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-semibold text-highlighted">{{ t('Search result') }}</span>
|
||||
<div class="w-37.5">
|
||||
<SegmentedTabs v-model="device" compact :items="items" />
|
||||
</div>
|
||||
</div>
|
||||
<UCard
|
||||
variant="outline"
|
||||
:ui="{
|
||||
root: 'rounded-none bg-default ring-0 border border-default divide-y-0 shadow-none',
|
||||
body: 'flex flex-col gap-1 p-3.5 sm:p-3.5',
|
||||
}"
|
||||
:class="device === 'mobile' ? 'md:w-70' : ''"
|
||||
data-testid="search-preview-card"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class="flex size-5.5 shrink-0 items-center justify-center rounded-full bg-elevated text-2xs/none font-semibold text-highlighted"
|
||||
>{{ siteInitial }}</span
|
||||
>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="text-xs/4 text-highlighted">{{ siteName }}</span>
|
||||
<span class="truncate text-2xs/3.5 text-muted"
|
||||
>{{ host }} › {{ slug.slice(0, SLUG_PATH_LIMIT) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="mt-1 break-words text-info"
|
||||
:class="device === 'desktop' ? 'text-lead/6' : 'text-lg/5.25'"
|
||||
data-testid="search-preview-title"
|
||||
>{{ shownTitle }}</span
|
||||
>
|
||||
<span
|
||||
class="text-sm/4.75 break-words text-toned"
|
||||
data-testid="search-preview-description"
|
||||
>{{ shownDescription }}</span
|
||||
>
|
||||
</UCard>
|
||||
<div v-if="chips.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
|
||||
</div>
|
||||
<span v-if="cutOff" class="text-xs text-muted" data-testid="search-preview-cut">{{
|
||||
t('Cut off here, the way a search engine would.')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, useId } from 'vue'
|
||||
import SuggestionList from '@/components/SuggestionList.vue'
|
||||
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||
import type { SeoCheckStatus, SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
remembered: boolean | null
|
||||
address: string
|
||||
phase: SeoCheckPhase
|
||||
status: SeoCheckStatus | null
|
||||
suggestions: SeoSuggestion[]
|
||||
sheet?: boolean
|
||||
}>(),
|
||||
{ sheet: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:remembered': [open: boolean]
|
||||
edit: [focus?: SeoFocus]
|
||||
preview: []
|
||||
retry: []
|
||||
go: [field: SuggestionField]
|
||||
}>()
|
||||
|
||||
const bodyId = useId()
|
||||
|
||||
const isOpen = computed(() => props.remembered ?? false)
|
||||
const checking = computed(() => isChecking(props.phase))
|
||||
const hidden = computed(() => !checking.value && props.status === 'hidden')
|
||||
const summary = computed(() => {
|
||||
if (props.phase === 'failed') {
|
||||
return t("Couldn't check this article.")
|
||||
}
|
||||
if (checking.value) {
|
||||
return t('Checking…')
|
||||
}
|
||||
if (props.status === 'hidden') {
|
||||
return t('Hidden from search')
|
||||
}
|
||||
if (props.suggestions.length === 1) {
|
||||
return t('1 suggestion')
|
||||
}
|
||||
if (props.suggestions.length > 1) {
|
||||
return t('{0} suggestions', [props.suggestions.length])
|
||||
}
|
||||
return t('Ready')
|
||||
})
|
||||
const warning = computed(
|
||||
() => !checking.value && props.status !== 'hidden' && props.suggestions.length > 0,
|
||||
)
|
||||
const buttonClass = computed(
|
||||
() =>
|
||||
`${props.sheet ? 'h-11' : 'h-8'} w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0`,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-3 border-b border-default pb-4" data-testid="seo-section">
|
||||
<UCollapsible :open="isOpen" @update:open="emit('update:remembered', $event)">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
class="min-h-6 w-full justify-start gap-2 p-0 text-sm font-semibold text-highlighted"
|
||||
:aria-controls="bodyId"
|
||||
data-testid="seo-toggle"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-down"
|
||||
class="size-3.5 shrink-0 transition-transform"
|
||||
:class="{ '-rotate-90': !isOpen }"
|
||||
/>
|
||||
{{ t('Search and sharing') }}
|
||||
</UButton>
|
||||
<template #content>
|
||||
<div :id="bodyId" class="mt-3 flex flex-col gap-3" data-testid="seo-body">
|
||||
<div class="flex flex-col gap-1 bg-elevated p-2" data-testid="seo-address">
|
||||
<span class="text-xs text-muted">{{ t('Address') }}</span>
|
||||
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
|
||||
address
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="hidden" class="flex gap-2 text-warning" data-testid="seo-hidden">
|
||||
<UIcon name="i-lucide-eye-off" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span class="text-pretty text-toned">{{
|
||||
t('Hidden from search engines. This only sets a flag in the API.')
|
||||
}}</span>
|
||||
</div>
|
||||
<SuggestionList
|
||||
compact
|
||||
:phase="phase"
|
||||
:suggestions="suggestions"
|
||||
@go="emit('go', $event)"
|
||||
@more="emit('edit', 'suggestions')"
|
||||
@retry="emit('retry')"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-search"
|
||||
:class="buttonClass"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
data-testid="seo-edit"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
{{ t('Edit search and sharing') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-braces"
|
||||
:class="buttonClass"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
data-testid="seo-preview"
|
||||
@click="emit('preview')"
|
||||
>
|
||||
{{ t('What the frontend gets') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
<span
|
||||
v-if="!isOpen"
|
||||
class="truncate pl-5.5"
|
||||
:class="warning ? 'text-warning' : 'text-muted'"
|
||||
data-testid="seo-summary"
|
||||
:title="summary"
|
||||
>{{ summary }}</span
|
||||
>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts" generic="V extends string">
|
||||
import { SEGMENT_TAB_ACTIVE } from '@/lib/segmentStyles'
|
||||
|
||||
defineProps<{
|
||||
items: { value: V; label: string }[]
|
||||
modelValue: V
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: V] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTabs
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
variant="pill"
|
||||
color="neutral"
|
||||
:content="false"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
list: 'gap-0 rounded-none border border-accented bg-default p-0',
|
||||
indicator: 'hidden',
|
||||
trigger: [
|
||||
'flex-1 rounded-none px-0 font-normal text-toned',
|
||||
SEGMENT_TAB_ACTIVE,
|
||||
compact ? 'h-11 text-xs md:h-6' : 'h-11 text-sm md:h-8',
|
||||
],
|
||||
label: 'truncate',
|
||||
}"
|
||||
@update:model-value="emit('update:modelValue', String($event) as V)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineProps<{ title: string; tag?: string; summary: string }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCollapsible
|
||||
v-model:open="open"
|
||||
:ui="{ root: 'flex flex-col gap-3 border border-default p-3' }"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
class="min-h-11 w-full justify-start gap-2 p-0 text-sm text-highlighted md:min-h-6"
|
||||
:aria-controls="bodyId"
|
||||
data-testid="seo-disclosure-toggle"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-down"
|
||||
class="size-3.5 shrink-0 transition-transform"
|
||||
:class="{ '-rotate-90': !open }"
|
||||
/>
|
||||
<span class="font-semibold">{{ title }}</span>
|
||||
<span
|
||||
v-if="tag"
|
||||
class="inline-flex min-h-5 items-center border border-dashed border-accented px-1.5 py-px font-mono text-2xs/4 font-medium text-toned"
|
||||
>{{ tag }}</span
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-right text-xs font-normal text-muted">{{
|
||||
summary
|
||||
}}</span>
|
||||
</UButton>
|
||||
<template #content>
|
||||
<div :id="bodyId" class="flex flex-col gap-3">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</template>
|
||||
@@ -0,0 +1,679 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { createReusableTemplate, useMediaQuery } from '@vueuse/core'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SearchPreview from '@/components/SearchPreview.vue'
|
||||
import SeoDisclosure from '@/components/SeoDisclosure.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import SocialCardPreview from '@/components/SocialCardPreview.vue'
|
||||
import SoftCounter from '@/components/SoftCounter.vue'
|
||||
import SuggestionList from '@/components/SuggestionList.vue'
|
||||
import type { SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useMediaNames } from '@/composables/useMediaNames'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import {
|
||||
SEO_LIMITS,
|
||||
canonicalIsValid,
|
||||
charCount,
|
||||
normalizeSlug,
|
||||
typedSlug,
|
||||
type ArticleSeo,
|
||||
} from '@/lib/articleSeo'
|
||||
import { slugify } from '@/lib/directory'
|
||||
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import {
|
||||
DESCRIPTION_RANGE,
|
||||
SOCIAL_TITLE_RANGE,
|
||||
TITLE_RANGE,
|
||||
hasSocialOverrides,
|
||||
siteDisplayName,
|
||||
previewDescription,
|
||||
previewDescriptionSource,
|
||||
socialImageSource,
|
||||
type PreviewDevice,
|
||||
type SeoPreviewInput,
|
||||
} from '@/lib/seoPreview'
|
||||
import type { SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const PANEL_FIELDS: SuggestionField[] = [
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
'cover_image_alt',
|
||||
'cover_image_id',
|
||||
'slug',
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ArticleSeo
|
||||
savedSlug: string
|
||||
articleTitle: string
|
||||
body: string
|
||||
site: Site | undefined
|
||||
phase: SeoCheckPhase
|
||||
suggestions: SeoSuggestion[]
|
||||
slugVerdict: { available: boolean; suggestion: string | null } | null
|
||||
checkedSlug: string | undefined
|
||||
slugTakenOnSave: string | null
|
||||
canonicalRejected: boolean
|
||||
saving: boolean
|
||||
notice: DirectoryNotice | null
|
||||
focus: SeoFocus | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ArticleSeo]
|
||||
check: [immediate: boolean]
|
||||
submit: []
|
||||
close: []
|
||||
preview: []
|
||||
go: [field: SuggestionField]
|
||||
}>()
|
||||
|
||||
const [DefineSuggestions, Suggestions] = createReusableTemplate()
|
||||
const [DefineFields, Fields] = createReusableTemplate()
|
||||
const [DefinePreviews, Previews] = createReusableTemplate()
|
||||
const [DefinePreviewButton, PreviewButton] = createReusableTemplate()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const names = useMediaNames()
|
||||
const layout = ref<HTMLElement | null>(null)
|
||||
const focusTarget = ref<SeoFocus | null>(props.focus)
|
||||
const device = ref<PreviewDevice>('desktop')
|
||||
const socialOpen = ref(false)
|
||||
const advancedOpen = ref(props.canonicalRejected)
|
||||
const canonicalMessage = ref('')
|
||||
const generatedSlug = computed(() => slugify(props.articleTitle, SEO_LIMITS.slug))
|
||||
const slugManual = ref(
|
||||
props.modelValue.slug !== '' && props.modelValue.slug !== generatedSlug.value,
|
||||
)
|
||||
const slugText = ref(props.modelValue.slug || generatedSlug.value)
|
||||
|
||||
const seo = computed(() => props.modelValue)
|
||||
const siteName = computed(() => siteDisplayName(props.site))
|
||||
const previewInput = computed<SeoPreviewInput>(() => ({
|
||||
seo: seo.value,
|
||||
title: props.articleTitle,
|
||||
body: props.body,
|
||||
site: props.site,
|
||||
}))
|
||||
const displaySlug = computed(() => (slugManual.value ? slugText.value : generatedSlug.value))
|
||||
const slugMode = computed(() => {
|
||||
if (slugManual.value) {
|
||||
return 'manual' as const
|
||||
}
|
||||
return generatedSlug.value ? ('auto' as const) : ('empty' as const)
|
||||
})
|
||||
const siteImageId = computed(() => props.site?.default_social_image_id ?? null)
|
||||
const siteImageName = computed(() => {
|
||||
const id = siteImageId.value
|
||||
if (id === null) {
|
||||
return ''
|
||||
}
|
||||
return names.itemOf(id)?.filename ?? t('Image #{0}', [id])
|
||||
})
|
||||
const coverItem = computed(() => names.itemOf(seo.value.cover_image_id))
|
||||
const socialItem = computed(() => names.itemOf(seo.value.social_image_id))
|
||||
const cardSource = computed(() => socialImageSource(seo.value, siteImageId.value !== null))
|
||||
const cardImageItem = computed(() => {
|
||||
const items = {
|
||||
social: socialItem.value,
|
||||
cover: coverItem.value,
|
||||
site: names.itemOf(siteImageId.value),
|
||||
none: null,
|
||||
}
|
||||
return items[cardSource.value]
|
||||
})
|
||||
const cardImageName = computed(() => {
|
||||
const labels = {
|
||||
social: socialItem.value?.filename ?? t('Image #{0}', [seo.value.social_image_id ?? 0]),
|
||||
cover: coverItem.value?.filename ?? t('Image #{0}', [seo.value.cover_image_id ?? 0]),
|
||||
site: siteImageName.value,
|
||||
none: '',
|
||||
}
|
||||
return labels[cardSource.value]
|
||||
})
|
||||
|
||||
const slugTaken = computed(() => {
|
||||
if (props.slugTakenOnSave !== null && props.slugTakenOnSave === seo.value.slug) {
|
||||
return true
|
||||
}
|
||||
const verdict = props.slugVerdict
|
||||
return (
|
||||
verdict !== null &&
|
||||
!verdict.available &&
|
||||
props.checkedSlug !== undefined &&
|
||||
props.checkedSlug === seo.value.slug
|
||||
)
|
||||
})
|
||||
const slugSuggestion = computed(() =>
|
||||
slugTaken.value && props.checkedSlug === seo.value.slug
|
||||
? (props.slugVerdict?.suggestion ?? '')
|
||||
: '',
|
||||
)
|
||||
const slugError = computed(() =>
|
||||
slugTaken.value
|
||||
? t('The slug "{0}" is already used on {1}.', [displaySlug.value, siteName.value])
|
||||
: '',
|
||||
)
|
||||
const slugUrl = computed(() => `${props.site?.base_url ?? ''}/${displaySlug.value}`)
|
||||
|
||||
const descriptionPlaceholder = computed(() => previewDescription(previewInput.value))
|
||||
const descriptionChipLabel = computed(() =>
|
||||
descriptionChip(previewDescriptionSource(previewInput.value)),
|
||||
)
|
||||
const excerptTooLong = computed(() => charCount(seo.value.excerpt) > SEO_LIMITS.excerpt)
|
||||
const excerptError = computed(() =>
|
||||
excerptTooLong.value ? t('Shorten the excerpt to 300 characters or fewer.') : '',
|
||||
)
|
||||
const coverHelp = computed(() => {
|
||||
if (seo.value.cover_image_id !== null || siteImageId.value === null) {
|
||||
return ''
|
||||
}
|
||||
return siteImageName.value
|
||||
? t('Using the site social image ({0}).', [siteImageName.value])
|
||||
: t('Using the site social image.')
|
||||
})
|
||||
const coverPicker = computed(() =>
|
||||
pickerCopy(t('Choose a cover image'), t('The cover for this article.')),
|
||||
)
|
||||
const socialPicker = computed(() =>
|
||||
pickerCopy(t('Choose a social image'), t('The image shown when this article is shared.')),
|
||||
)
|
||||
const customised = computed(() => hasSocialOverrides(seo.value))
|
||||
const socialSummary = computed(() => {
|
||||
if (customised.value) {
|
||||
return t('Customised')
|
||||
}
|
||||
return socialOpen.value ? '' : t('Uses the meta values')
|
||||
})
|
||||
const advancedSummary = computed(() => {
|
||||
if (advancedOpen.value) {
|
||||
return ''
|
||||
}
|
||||
return seo.value.canonical_url ? t('Canonical URL set') : t('Canonical URL: automatic')
|
||||
})
|
||||
const canonicalError = computed(() =>
|
||||
canonicalMessage.value || props.canonicalRejected
|
||||
? t('Enter a full address starting with https://')
|
||||
: '',
|
||||
)
|
||||
const canonicalPlaceholder = computed(() => slugUrl.value)
|
||||
|
||||
function pickerCopy(title: string, description: string) {
|
||||
return { title, description, confirmLabel: t('Use image') }
|
||||
}
|
||||
|
||||
function update(patch: Partial<ArticleSeo>, immediate = false) {
|
||||
emit('update:modelValue', { ...props.modelValue, ...patch })
|
||||
emit('check', immediate)
|
||||
}
|
||||
|
||||
function typeText(
|
||||
key:
|
||||
| 'seo_title'
|
||||
| 'seo_description'
|
||||
| 'excerpt'
|
||||
| 'cover_image_alt'
|
||||
| 'social_title'
|
||||
| 'social_description',
|
||||
value: string,
|
||||
) {
|
||||
update({ [key]: value })
|
||||
}
|
||||
|
||||
function typeCanonical(value: string) {
|
||||
canonicalMessage.value = ''
|
||||
update({ canonical_url: value })
|
||||
}
|
||||
|
||||
function canonicalBlocked() {
|
||||
if (canonicalIsValid(seo.value.canonical_url)) {
|
||||
return false
|
||||
}
|
||||
canonicalMessage.value = 'invalid'
|
||||
advancedOpen.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
function checkCanonical() {
|
||||
canonicalBlocked()
|
||||
}
|
||||
|
||||
function autoSlug() {
|
||||
return generatedSlug.value === props.savedSlug ? props.savedSlug : ''
|
||||
}
|
||||
|
||||
function unlockSlug() {
|
||||
slugManual.value = true
|
||||
slugText.value = generatedSlug.value
|
||||
}
|
||||
|
||||
function typeSlug(value: string) {
|
||||
slugText.value = typedSlug(value)
|
||||
update({ slug: normalizeSlug(slugText.value) })
|
||||
}
|
||||
|
||||
function resetSlug() {
|
||||
slugManual.value = false
|
||||
slugText.value = generatedSlug.value
|
||||
update({ slug: autoSlug() }, true)
|
||||
}
|
||||
|
||||
function useSuggestedSlug(suggestion: string) {
|
||||
slugManual.value = true
|
||||
slugText.value = suggestion
|
||||
update({ slug: suggestion }, true)
|
||||
}
|
||||
|
||||
function checkSlug() {
|
||||
if (slugManual.value) {
|
||||
emit('check', true)
|
||||
}
|
||||
}
|
||||
|
||||
function setCover(id: number | null) {
|
||||
update({ cover_image_id: id }, true)
|
||||
}
|
||||
|
||||
function setSocialImage(id: number | null) {
|
||||
update({ social_image_id: id }, true)
|
||||
}
|
||||
|
||||
function setNoindex(value: boolean) {
|
||||
update({ noindex: value }, true)
|
||||
}
|
||||
|
||||
function remember(item: MediaItem | null) {
|
||||
if (item) {
|
||||
names.remember(item)
|
||||
}
|
||||
}
|
||||
|
||||
function goTo(field: SuggestionField) {
|
||||
if (!PANEL_FIELDS.includes(field)) {
|
||||
emit('go', field)
|
||||
return
|
||||
}
|
||||
focusTarget.value = field
|
||||
nextTick(() => layout.value?.querySelector<HTMLElement>('[data-autofocus]')?.focus())
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (excerptTooLong.value) {
|
||||
return
|
||||
}
|
||||
if (!canonicalBlocked()) {
|
||||
emit('submit')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.canonicalRejected,
|
||||
(rejected) => {
|
||||
if (rejected) {
|
||||
advancedOpen.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id])
|
||||
})
|
||||
|
||||
const DISABLED_FIELD = 'disabled:opacity-40'
|
||||
const fieldUi = { base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}` }
|
||||
const monoFieldUi = {
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`,
|
||||
}
|
||||
const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD}` }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefineSuggestions>
|
||||
<div
|
||||
class="flex flex-col gap-2 focus:outline-none"
|
||||
:data-autofocus="focusTarget === 'suggestions' ? '' : undefined"
|
||||
:tabindex="focusTarget === 'suggestions' ? -1 : undefined"
|
||||
data-testid="seo-panel-suggestions"
|
||||
>
|
||||
<span v-if="isDesktop" class="font-semibold text-highlighted">{{
|
||||
t('Suggestions')
|
||||
}}</span>
|
||||
<SuggestionList
|
||||
:phase="phase"
|
||||
:suggestions="suggestions"
|
||||
@go="goTo"
|
||||
@retry="emit('check', true)"
|
||||
/>
|
||||
</div>
|
||||
</DefineSuggestions>
|
||||
|
||||
<DefinePreviewButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-braces"
|
||||
class="h-11 gap-1.5 self-start border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:w-full max-md:justify-center max-md:text-sm md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
data-testid="seo-panel-preview"
|
||||
@click="emit('preview')"
|
||||
>
|
||||
{{ t('What the frontend gets') }}
|
||||
</UButton>
|
||||
</DefinePreviewButton>
|
||||
|
||||
<DefinePreviews>
|
||||
<SearchPreview
|
||||
v-model:device="device"
|
||||
:input="previewInput"
|
||||
:site="site"
|
||||
:slug="displaySlug"
|
||||
/>
|
||||
<SocialCardPreview
|
||||
:input="previewInput"
|
||||
:site="site"
|
||||
:image-source="cardSource"
|
||||
:image-name="cardImageName"
|
||||
:image-item="cardImageItem"
|
||||
/>
|
||||
</DefinePreviews>
|
||||
|
||||
<DefineFields>
|
||||
<div class="flex min-w-0 flex-col gap-4.5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<SlugField
|
||||
:label="t('URL slug')"
|
||||
:mode="slugMode"
|
||||
:value="displaySlug"
|
||||
:error="slugError"
|
||||
:suggestion="slugSuggestion"
|
||||
:help="
|
||||
t(
|
||||
'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on {0}.',
|
||||
[siteName],
|
||||
)
|
||||
"
|
||||
:manual-help="
|
||||
t('Lowercase letters, numbers and hyphens. Unique on {0}.', [siteName])
|
||||
"
|
||||
:reset-label="t('Reset to match the title')"
|
||||
:placeholder="t('Generated from the title')"
|
||||
:edit-label="t('Edit slug')"
|
||||
:maxlength="SEO_LIMITS.slug"
|
||||
:autofocus="focusTarget === 'slug'"
|
||||
@update:value="typeSlug"
|
||||
@unlock="unlockSlug"
|
||||
@reset="resetSlug"
|
||||
@suggest="useSuggestedSlug"
|
||||
@blur="checkSlug"
|
||||
/>
|
||||
<div class="flex min-w-0 items-center gap-1.5 bg-elevated px-2.5 py-2">
|
||||
<UIcon name="i-lucide-link" class="size-3 shrink-0 text-muted" />
|
||||
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
|
||||
slugUrl
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField
|
||||
:label="t('Meta title')"
|
||||
optional
|
||||
:help="t('Recommended 30 to 60 characters. This is a guide, not a limit.')"
|
||||
>
|
||||
<template #hint>
|
||||
<SoftCounter
|
||||
:value="charCount(seo.seo_title)"
|
||||
:min="TITLE_RANGE.min"
|
||||
:max="TITLE_RANGE.max"
|
||||
/>
|
||||
</template>
|
||||
<UInput
|
||||
:model-value="seo.seo_title"
|
||||
:placeholder="articleTitle"
|
||||
:maxlength="SEO_LIMITS.seo_title"
|
||||
:data-autofocus="focusTarget === 'seo_title' ? '' : undefined"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="fieldUi"
|
||||
@update:model-value="typeText('seo_title', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.seo_title" :label="chipLabel('article_title')" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField
|
||||
:label="t('Meta description')"
|
||||
optional
|
||||
:help="t('Recommended 70 to 160 characters.')"
|
||||
>
|
||||
<template #hint>
|
||||
<SoftCounter
|
||||
:value="charCount(seo.seo_description)"
|
||||
:min="DESCRIPTION_RANGE.min"
|
||||
:max="DESCRIPTION_RANGE.max"
|
||||
/>
|
||||
</template>
|
||||
<UTextarea
|
||||
:model-value="seo.seo_description"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
:maxlength="SEO_LIMITS.seo_description"
|
||||
:data-autofocus="focusTarget === 'seo_description' ? '' : undefined"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
:ui="areaUi"
|
||||
@update:model-value="typeText('seo_description', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip
|
||||
v-if="descriptionChipLabel && !seo.seo_description"
|
||||
:label="descriptionChipLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DirectoryField
|
||||
:label="t('Excerpt')"
|
||||
optional
|
||||
:error="excerptError"
|
||||
:help="
|
||||
t(
|
||||
'Used as the description when there is none, and as the teaser for the frontend.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #hint>
|
||||
<span
|
||||
class="font-mono text-xs"
|
||||
:class="excerptTooLong ? 'text-error' : 'text-muted'"
|
||||
data-testid="excerpt-counter"
|
||||
>{{ charCount(seo.excerpt) }} / {{ SEO_LIMITS.excerpt }}</span
|
||||
>
|
||||
</template>
|
||||
<UTextarea
|
||||
:model-value="seo.excerpt"
|
||||
:placeholder="t('A short summary. It is also sent as the article teaser.')"
|
||||
:color="excerptTooLong ? 'error' : undefined"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
:ui="areaUi"
|
||||
@update:model-value="typeText('excerpt', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<MediaField
|
||||
:model-value="seo.cover_image_id"
|
||||
:item="coverItem"
|
||||
:label="t('Cover image')"
|
||||
:help="coverHelp"
|
||||
:picker="coverPicker"
|
||||
:disabled="saving"
|
||||
:autofocus-choose="focusTarget === 'cover_image_id'"
|
||||
@update:item="remember"
|
||||
@update:model-value="setCover"
|
||||
/>
|
||||
<DirectoryField
|
||||
v-if="seo.cover_image_id !== null"
|
||||
:label="t('Alt text')"
|
||||
:help="
|
||||
seo.cover_image_alt
|
||||
? ''
|
||||
: t(
|
||||
'Needed before the cover counts as ready. Sent as image alt in the API.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<UInput
|
||||
:model-value="seo.cover_image_alt"
|
||||
:placeholder="t('Describe the image for people who can\'t see it')"
|
||||
:maxlength="SEO_LIMITS.cover_image_alt"
|
||||
:data-autofocus="focusTarget === 'cover_image_alt' ? '' : undefined"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="fieldUi"
|
||||
@update:model-value="typeText('cover_image_alt', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
|
||||
<SeoDisclosure
|
||||
v-model:open="socialOpen"
|
||||
:title="t('Social sharing')"
|
||||
:tag="t('Optional')"
|
||||
:summary="socialSummary"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social title')" optional>
|
||||
<template #hint>
|
||||
<SoftCounter
|
||||
:value="charCount(seo.social_title)"
|
||||
:min="SOCIAL_TITLE_RANGE.min"
|
||||
:max="SOCIAL_TITLE_RANGE.max"
|
||||
/>
|
||||
</template>
|
||||
<UInput
|
||||
:model-value="seo.social_title"
|
||||
:placeholder="seo.seo_title || articleTitle"
|
||||
:maxlength="SEO_LIMITS.social_title"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="fieldUi"
|
||||
@update:model-value="typeText('social_title', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.social_title" :label="chipLabel('meta_title')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social description')" optional>
|
||||
<UTextarea
|
||||
:model-value="seo.social_description"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
:maxlength="SEO_LIMITS.social_description"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
:ui="areaUi"
|
||||
@update:model-value="typeText('social_description', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip
|
||||
v-if="!seo.social_description"
|
||||
:label="chipLabel('meta_description')"
|
||||
/>
|
||||
</div>
|
||||
<MediaField
|
||||
:model-value="seo.social_image_id"
|
||||
:item="socialItem"
|
||||
:label="t('Social image')"
|
||||
:help="seo.social_image_id === null ? t('Using the cover image.') : ''"
|
||||
:picker="socialPicker"
|
||||
:disabled="saving"
|
||||
@update:item="remember"
|
||||
@update:model-value="setSocialImage"
|
||||
/>
|
||||
</SeoDisclosure>
|
||||
|
||||
<SeoDisclosure
|
||||
v-model:open="advancedOpen"
|
||||
:title="t('Advanced')"
|
||||
:summary="advancedSummary"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField
|
||||
:label="t('Canonical URL')"
|
||||
optional
|
||||
:error="canonicalError"
|
||||
:help="t('Only change this if the same content lives at another address.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="seo.canonical_url"
|
||||
:placeholder="canonicalPlaceholder"
|
||||
:maxlength="SEO_LIMITS.canonical_url"
|
||||
:color="canonicalError ? 'error' : undefined"
|
||||
:aria-invalid="canonicalError ? 'true' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="monoFieldUi"
|
||||
@update:model-value="typeCanonical(String($event))"
|
||||
@focusout="checkCanonical"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.canonical_url" :label="chipLabel('canonical')" />
|
||||
</div>
|
||||
</SeoDisclosure>
|
||||
|
||||
<USwitch
|
||||
:model-value="seo.noindex"
|
||||
:label="t('Hide from search engines')"
|
||||
:description="
|
||||
t('Sets noindex in the API. It doesn\'t change who can read the article.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'items-start gap-3',
|
||||
base: 'mt-0.5 h-4.5 w-8 data-[disabled]:opacity-40',
|
||||
thumb: 'size-3.5',
|
||||
label: 'font-medium text-highlighted',
|
||||
description: 'text-xs/4.5 text-pretty text-muted',
|
||||
}"
|
||||
@update:model-value="setNoindex(Boolean($event))"
|
||||
/>
|
||||
</div>
|
||||
</DefineFields>
|
||||
|
||||
<DirectoryPanel
|
||||
wide
|
||||
:title="t('Search and sharing')"
|
||||
:submit-label="t('Save changes')"
|
||||
:saving="saving"
|
||||
:submit-disabled="excerptTooLong"
|
||||
:notice="notice"
|
||||
@close="emit('close')"
|
||||
@submit="submit"
|
||||
>
|
||||
<div v-if="isDesktop" ref="layout" class="flex items-start gap-7">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Fields />
|
||||
</div>
|
||||
<div class="sticky top-0 flex w-85 shrink-0 flex-col gap-4.5">
|
||||
<Previews />
|
||||
<Suggestions />
|
||||
<PreviewButton />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="layout" class="flex flex-col gap-4.5">
|
||||
<Suggestions />
|
||||
<Fields />
|
||||
<Previews />
|
||||
<PreviewButton />
|
||||
</div>
|
||||
</DirectoryPanel>
|
||||
</template>
|
||||
@@ -18,6 +18,10 @@ const props = withDefaults(
|
||||
locked?: boolean
|
||||
lockedHelp?: string
|
||||
editLabel?: string
|
||||
resetLabel?: string
|
||||
placeholder?: string
|
||||
autofocus?: boolean
|
||||
maxlength?: number
|
||||
}>(),
|
||||
{
|
||||
name: undefined,
|
||||
@@ -28,6 +32,10 @@ const props = withDefaults(
|
||||
locked: false,
|
||||
lockedHelp: '',
|
||||
editLabel: '',
|
||||
resetLabel: '',
|
||||
placeholder: '',
|
||||
autofocus: false,
|
||||
maxlength: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -42,7 +50,7 @@ const emit = defineEmits<{
|
||||
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
|
||||
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
|
||||
const inputUi = computed(() => ({
|
||||
base: `h-11 font-mono text-base md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5' : ''}`,
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
|
||||
}))
|
||||
const showReset = computed(
|
||||
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
|
||||
@@ -57,6 +65,8 @@ const shownHelp = computed(() => {
|
||||
return props.help
|
||||
})
|
||||
const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
||||
const resetText = computed(() => props.resetLabel || t('Reset to match the name'))
|
||||
const placeholderText = computed(() => props.placeholder || t('Generated from the name'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,7 +75,9 @@ const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
||||
<UInput
|
||||
:model-value="value"
|
||||
:readonly="readonly"
|
||||
:placeholder="t('Generated from the name')"
|
||||
:placeholder="placeholderText"
|
||||
:maxlength="maxlength"
|
||||
:data-autofocus="autofocus ? '' : undefined"
|
||||
:color="error ? 'error' : undefined"
|
||||
:highlight="mode === 'manual' && !error"
|
||||
class="w-full"
|
||||
@@ -120,7 +132,7 @@ const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ t('Reset to match the name') }}
|
||||
{{ resetText }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { hostOf } from '@/lib/directory'
|
||||
import { chipLabel } from '@/lib/seoCopy'
|
||||
import {
|
||||
socialDescription,
|
||||
socialTitle,
|
||||
type SeoPreviewInput,
|
||||
type SocialImageSource,
|
||||
} from '@/lib/seoPreview'
|
||||
|
||||
const props = defineProps<{
|
||||
input: SeoPreviewInput
|
||||
site: Site | undefined
|
||||
imageSource: SocialImageSource
|
||||
imageName: string
|
||||
imageItem: MediaItem | null
|
||||
}>()
|
||||
|
||||
const imageFailed = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.imageItem?.url,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const host = computed(() => hostOf(props.site?.base_url ?? ''))
|
||||
const title = computed(() => socialTitle(props.input))
|
||||
const description = computed(() => socialDescription(props.input))
|
||||
const chips = computed(() => {
|
||||
const imageChips: Record<SocialImageSource, string | null> = {
|
||||
social: null,
|
||||
cover: chipLabel('cover_image'),
|
||||
site: chipLabel('site_image'),
|
||||
none: chipLabel('no_image'),
|
||||
}
|
||||
return [
|
||||
props.input.seo.social_title ? null : chipLabel('meta_title'),
|
||||
props.input.seo.social_description ? null : chipLabel('meta_description'),
|
||||
imageChips[props.imageSource],
|
||||
].filter((chip): chip is string => chip !== null)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2" data-testid="social-card">
|
||||
<span class="font-semibold text-highlighted">{{ t('Social card') }}</span>
|
||||
<UCard
|
||||
variant="outline"
|
||||
:ui="{
|
||||
root: 'rounded-none bg-default ring-0 border border-default divide-y-0 shadow-none',
|
||||
body: 'p-0 sm:p-0',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="social-card-image relative flex aspect-191/100 items-center justify-center overflow-hidden font-mono text-2xs text-muted"
|
||||
data-testid="social-card-image"
|
||||
>
|
||||
<span v-if="imageSource !== 'none'">{{ imageName }}</span>
|
||||
<img
|
||||
v-if="imageItem && !imageFailed"
|
||||
:src="imageItem.url"
|
||||
alt=""
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 border-t border-default px-3 py-2.5">
|
||||
<span class="text-2xs/4 text-muted uppercase">{{ host }}</span>
|
||||
<span
|
||||
class="truncate font-semibold text-highlighted"
|
||||
data-testid="social-card-title"
|
||||
>{{ title }}</span
|
||||
>
|
||||
<span
|
||||
class="line-clamp-2 text-xs/4.5 text-toned"
|
||||
data-testid="social-card-description"
|
||||
>{{ description }}</span
|
||||
>
|
||||
</div>
|
||||
</UCard>
|
||||
<div v-if="chips.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.social-card-image {
|
||||
background:
|
||||
repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px),
|
||||
var(--rc-sand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ value: number; min: number; max: number }>()
|
||||
|
||||
const outside = computed(
|
||||
() => props.value > 0 && (props.value < props.min || props.value > props.max),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="font-mono text-xs"
|
||||
:class="outside ? 'text-warning' : 'text-muted'"
|
||||
data-testid="soft-counter"
|
||||
>{{ value }} / {{ max }}</span
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||
import type { SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
||||
|
||||
const COMPACT_LIMIT = 3
|
||||
const SKELETON_WIDTHS = ['w-7/10', 'w-9/10', 'w-11/20']
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
phase: SeoCheckPhase
|
||||
suggestions: SeoSuggestion[]
|
||||
compact?: boolean
|
||||
}>(),
|
||||
{ compact: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
go: [field: SuggestionField]
|
||||
more: []
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const shown = computed(() =>
|
||||
props.compact ? props.suggestions.slice(0, COMPACT_LIMIT) : props.suggestions,
|
||||
)
|
||||
const hiddenCount = computed(() => props.suggestions.length - shown.value.length)
|
||||
const checking = computed(() => isChecking(props.phase))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 bg-elevated p-3"
|
||||
:aria-busy="checking ? 'true' : undefined"
|
||||
data-testid="suggestion-list"
|
||||
>
|
||||
<template v-if="phase === 'failed'">
|
||||
<div class="flex items-start gap-2 text-toned">
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0 text-error" />
|
||||
<span>{{ t("Couldn't check this article.") }}</span>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 self-start border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
<template v-else-if="checking">
|
||||
<div class="flex items-center gap-2 text-toned" role="status">
|
||||
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||
<span>{{ t('Checking this article…') }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-for="width in SKELETON_WIDTHS"
|
||||
:key="width"
|
||||
class="block h-3 bg-accented"
|
||||
:class="width"
|
||||
/>
|
||||
</template>
|
||||
<div v-else-if="suggestions.length === 0" class="flex items-start gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="suggestion in shown"
|
||||
:key="suggestion.code"
|
||||
class="flex items-start gap-2"
|
||||
data-testid="suggestion"
|
||||
>
|
||||
<UIcon name="i-lucide-lightbulb" class="mt-0.5 size-3.5 shrink-0 text-warning" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-pretty text-highlighted">{{
|
||||
suggestionMessage(suggestion)
|
||||
}}</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-lucide-arrow-right"
|
||||
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('go', suggestion.field)"
|
||||
>
|
||||
{{ suggestionLinkLabel(suggestion.field) }}
|
||||
</UButton>
|
||||
</div>
|
||||
</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
|
||||
>
|
||||
<UButton
|
||||
v-if="hiddenCount > 0"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
class="min-h-11 self-start p-0 text-xs text-muted md:min-h-5"
|
||||
data-testid="suggestions-more"
|
||||
@click="emit('more')"
|
||||
>
|
||||
{{ t('+{0} more in Search and sharing', [hiddenCount]) }}
|
||||
</UButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,230 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||
import { EMPTY_SEO, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
function reply(status: number, body: unknown = null, text = '') {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
const saved: ArticleSeo = {
|
||||
...EMPTY_SEO,
|
||||
slug: 'how-we-price-a-bathroom-renovation',
|
||||
cover_image_id: 3,
|
||||
}
|
||||
|
||||
function loaded(sent = vi.fn()) {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = sent.mockResolvedValue(reply(200, {})) as unknown as typeof fetch
|
||||
const draft = useArticleDraft(12)
|
||||
draft.load('<p>Body</p>', new Date('2026-09-26T11:02:00Z'), undefined, saved)
|
||||
return { draft, sent }
|
||||
}
|
||||
|
||||
function lastBody(sent: ReturnType<typeof vi.fn>) {
|
||||
return JSON.parse((sent.mock.calls.at(-1)![1] as RequestInit).body as string)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('The API types carry the SEO fields', () => {
|
||||
it('reads every SEO field of an article, and falls back to empty values for a list item', () => {
|
||||
const article: Article = {
|
||||
id: 12,
|
||||
title: 'T',
|
||||
body: '',
|
||||
is_online: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
slug: 'a-slug',
|
||||
excerpt: 'An excerpt',
|
||||
seo_title: 'Title',
|
||||
seo_description: 'Description',
|
||||
cover_image_id: 4,
|
||||
cover_image_alt: 'Alt',
|
||||
social_title: 'Social',
|
||||
social_description: 'Social description',
|
||||
social_image_id: null,
|
||||
canonical_url: 'https://renew.example/a',
|
||||
noindex: true,
|
||||
}
|
||||
|
||||
expect(seoOf(article)).toEqual({
|
||||
slug: 'a-slug',
|
||||
excerpt: 'An excerpt',
|
||||
seo_title: 'Title',
|
||||
seo_description: 'Description',
|
||||
cover_image_id: 4,
|
||||
cover_image_alt: 'Alt',
|
||||
social_title: 'Social',
|
||||
social_description: 'Social description',
|
||||
social_image_id: null,
|
||||
canonical_url: 'https://renew.example/a',
|
||||
noindex: true,
|
||||
})
|
||||
expect(seoOf({ slug: 'only-a-slug' })).toEqual({ ...EMPTY_SEO, slug: 'only-a-slug' })
|
||||
})
|
||||
|
||||
it('reads the site fields the previews need', () => {
|
||||
const site: Site = {
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index',
|
||||
locale: null,
|
||||
site_name: 'Renew',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: 'About Renew',
|
||||
is_default: true,
|
||||
article_count: 1,
|
||||
}
|
||||
|
||||
expect([site.site_name, site.title_template, site.default_description]).toEqual([
|
||||
'Renew',
|
||||
'%title% | %site%',
|
||||
'About Renew',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('The draft sends only the SEO fields that changed', () => {
|
||||
it('sends seo_title alone for a meta title edit', async () => {
|
||||
const { draft, sent } = loaded()
|
||||
|
||||
draft.editSeo({ seo_title: 'A new meta title' })
|
||||
expect(draft.state.value).toBe('dirty')
|
||||
await draft.save()
|
||||
|
||||
expect(lastBody(sent)).toEqual({ seo_title: 'A new meta title' })
|
||||
})
|
||||
|
||||
it('sends null for a removed image and an empty string for a reset slug', async () => {
|
||||
const { draft, sent } = loaded()
|
||||
|
||||
draft.editSeo({ cover_image_id: null, slug: '' })
|
||||
await draft.save()
|
||||
|
||||
expect(lastBody(sent)).toEqual({ cover_image_id: null, slug: '' })
|
||||
})
|
||||
|
||||
it('sends no request when the edited value equals the saved one', async () => {
|
||||
const { draft, sent } = loaded()
|
||||
|
||||
draft.editSeo({ seo_title: '' })
|
||||
|
||||
expect(draft.state.value).toBe('clean')
|
||||
await draft.save()
|
||||
expect(sent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('puts a pending body edit and a SEO edit in the same PATCH', async () => {
|
||||
const { draft, sent } = loaded()
|
||||
|
||||
draft.edit('<p>Edited</p>')
|
||||
draft.editSeo({ noindex: true })
|
||||
await draft.save()
|
||||
|
||||
expect(sent).toHaveBeenCalledTimes(1)
|
||||
expect(lastBody(sent)).toEqual({ noindex: true, body: '<p>Edited</p>' })
|
||||
})
|
||||
|
||||
it('adopts the values the API returns, such as a regenerated slug', async () => {
|
||||
const { draft, sent } = loaded()
|
||||
sent.mockResolvedValue(reply(200, { slug: 'regenerated-slug' }))
|
||||
|
||||
draft.editSeo({ slug: '' })
|
||||
await draft.save()
|
||||
|
||||
expect(draft.seo.value.slug).toBe('regenerated-slug')
|
||||
expect(draft.savedSeo.value.slug).toBe('regenerated-slug')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The SEO fields are mirrored in sessionStorage', () => {
|
||||
it('stores them on a 401 and restores them as an unsaved draft', async () => {
|
||||
const { draft, sent } = loaded()
|
||||
sent.mockResolvedValue(reply(401))
|
||||
draft.editSeo({ seo_title: 'Kept across the login' })
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('unauthorized')
|
||||
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)).toMatchObject({
|
||||
seo_title: 'Kept across the login',
|
||||
})
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const restored = useArticleDraft(12)
|
||||
restored.load('<p>Body</p>', new Date(), undefined, saved)
|
||||
expect(restored.state.value).toBe('dirty')
|
||||
expect(restored.seo.value.seo_title).toBe('Kept across the login')
|
||||
expect(restored.savedSeo.value.seo_title).toBe('')
|
||||
})
|
||||
|
||||
it('clears the mirror after a successful save', async () => {
|
||||
const { draft } = loaded()
|
||||
draft.editSeo({ seo_title: 'Soon saved' })
|
||||
draft.mirrorToStorage()
|
||||
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).not.toBeNull()
|
||||
|
||||
await draft.save()
|
||||
|
||||
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps working in memory when sessionStorage throws', async () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('blocked', 'SecurityError')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('blocked', 'SecurityError')
|
||||
})
|
||||
const { draft, sent } = loaded()
|
||||
draft.editSeo({ seo_title: 'In memory' })
|
||||
draft.mirrorToStorage()
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('saved')
|
||||
expect(lastBody(sent)).toEqual({ seo_title: 'In memory' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('The save outcomes for a refused SEO edit', () => {
|
||||
it('answers slug-taken for a 409 slug_taken and keeps the draft dirty', async () => {
|
||||
const { draft, sent } = loaded()
|
||||
sent.mockResolvedValue(reply(409, null, 'slug_taken'))
|
||||
draft.editSeo({ slug: 'bathroom-costs' })
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('slug-taken')
|
||||
expect(draft.state.value).toBe('dirty')
|
||||
expect(draft.savedSeo.value.slug).toBe('how-we-price-a-bathroom-renovation')
|
||||
expect(draft.seo.value.slug).toBe('bathroom-costs')
|
||||
})
|
||||
|
||||
it('answers invalid-url for a 422 invalid_url and keeps the draft dirty', async () => {
|
||||
const { draft, sent } = loaded()
|
||||
sent.mockResolvedValue(reply(422, null, 'invalid_url'))
|
||||
draft.editSeo({ canonical_url: 'https://' })
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('invalid-url')
|
||||
expect(draft.state.value).toBe('dirty')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article, Byline } from '@/composables/useArticles'
|
||||
import { sameIds, toBylines } from '@/lib/articleMeta'
|
||||
import { EMPTY_SEO, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
|
||||
|
||||
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
||||
@@ -13,6 +14,8 @@ export type SaveOutcome =
|
||||
| 'unauthorized'
|
||||
| 'site-gone'
|
||||
| 'author-gone'
|
||||
| 'slug-taken'
|
||||
| 'invalid-url'
|
||||
|
||||
export interface ArticleMeta {
|
||||
siteId: number | null
|
||||
@@ -27,6 +30,13 @@ function metaStorageKey(articleId: number) {
|
||||
return `renewcms:article-draft-meta:${articleId}`
|
||||
}
|
||||
|
||||
function seoStorageKey(articleId: number) {
|
||||
return `renewcms:article-draft-seo:${articleId}`
|
||||
}
|
||||
|
||||
const SLUG_TAKEN_CODE = 'slug_taken'
|
||||
const INVALID_URL_CODE = 'invalid_url'
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(key)
|
||||
@@ -67,6 +77,18 @@ function readMirroredMeta(articleId: number): ArticleMeta | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readMirroredSeo(articleId: number, base: ArticleSeo): ArticleSeo | null {
|
||||
const raw = readStorage(seoStorageKey(articleId))
|
||||
if (raw === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return seoOf(JSON.parse(raw), base)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readMirroredDraft(articleId: number): string | null {
|
||||
return readStorage(draftStorageKey(articleId))
|
||||
}
|
||||
@@ -78,6 +100,8 @@ export function useArticleDraft(articleId: number) {
|
||||
const bodyEdited = ref(false)
|
||||
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
||||
const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
||||
const seo = ref<ArticleSeo>({ ...EMPTY_SEO })
|
||||
const savedSeo = ref<ArticleSeo>({ ...EMPTY_SEO })
|
||||
const businessState = ref<ArticleDraftBusinessState>('clean')
|
||||
const savedAt = ref<Date | null>(null)
|
||||
const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine)
|
||||
@@ -112,6 +136,10 @@ export function useArticleDraft(articleId: number) {
|
||||
return !sameIds(meta.value.authors, savedMeta.value.authors)
|
||||
}
|
||||
|
||||
function seoPatch() {
|
||||
return seoChanges(seo.value, savedSeo.value)
|
||||
}
|
||||
|
||||
function mirrorToStorage() {
|
||||
if (bodyEdited.value) {
|
||||
writeStorage(draftStorageKey(articleId), body.value)
|
||||
@@ -125,22 +153,35 @@ export function useArticleDraft(articleId: number) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (Object.keys(seoPatch()).length > 0) {
|
||||
writeStorage(seoStorageKey(articleId), JSON.stringify(seo.value))
|
||||
}
|
||||
}
|
||||
|
||||
function clearStorage() {
|
||||
removeStorage(draftStorageKey(articleId))
|
||||
removeStorage(metaStorageKey(articleId))
|
||||
removeStorage(seoStorageKey(articleId))
|
||||
}
|
||||
|
||||
function load(initialBody: string, initialSavedAt: Date, initialMeta?: ArticleMeta) {
|
||||
function load(
|
||||
initialBody: string,
|
||||
initialSavedAt: Date,
|
||||
initialMeta?: ArticleMeta,
|
||||
initialSeo?: ArticleSeo,
|
||||
) {
|
||||
const mirrored = readMirroredDraft(articleId)
|
||||
const mirroredMeta = readMirroredMeta(articleId)
|
||||
savedAt.value = initialSavedAt
|
||||
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
|
||||
meta.value = mirroredMeta ?? savedMeta.value
|
||||
savedSeo.value = initialSeo ?? { ...EMPTY_SEO }
|
||||
const mirroredSeo = readMirroredSeo(articleId, savedSeo.value)
|
||||
seo.value = mirroredSeo ?? savedSeo.value
|
||||
bodyEdited.value = mirrored !== null
|
||||
body.value = mirrored ?? initialBody
|
||||
businessState.value = mirrored !== null || mirroredMeta !== null ? 'dirty' : 'clean'
|
||||
const restored = mirrored !== null || mirroredMeta !== null || mirroredSeo !== null
|
||||
businessState.value = restored ? 'dirty' : 'clean'
|
||||
}
|
||||
|
||||
function edit(newBody: string) {
|
||||
@@ -154,8 +195,24 @@ export function useArticleDraft(articleId: number) {
|
||||
businessState.value = 'dirty'
|
||||
}
|
||||
|
||||
function editSeo(next: Partial<ArticleSeo>) {
|
||||
const merged = { ...seo.value, ...next }
|
||||
if (Object.keys(seoChanges(merged, seo.value)).length === 0) {
|
||||
return
|
||||
}
|
||||
seo.value = merged
|
||||
businessState.value = 'dirty'
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
if (businessState.value === 'error') {
|
||||
businessState.value = 'dirty'
|
||||
}
|
||||
}
|
||||
|
||||
function savePayload() {
|
||||
return {
|
||||
...seoPatch(),
|
||||
...(bodyEdited.value ? { body: body.value } : {}),
|
||||
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
|
||||
...(authorsChanged()
|
||||
@@ -164,6 +221,14 @@ export function useArticleDraft(articleId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function rejectedOutcome(err: unknown): SaveOutcome | null {
|
||||
const code = codeOf(err)
|
||||
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
|
||||
return 'slug-taken'
|
||||
}
|
||||
return statusOf(err) === 422 && code === INVALID_URL_CODE ? 'invalid-url' : null
|
||||
}
|
||||
|
||||
function goneOutcome(err: unknown): SaveOutcome | null {
|
||||
if (statusOf(err) !== 404) {
|
||||
return null
|
||||
@@ -179,20 +244,28 @@ export function useArticleDraft(articleId: number) {
|
||||
businessState.value = 'saving'
|
||||
const payload = savePayload()
|
||||
try {
|
||||
if (Object.keys(payload).length > 0) {
|
||||
await request<Article>(`/v1/article/${articleId}?format=html`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
const saved =
|
||||
Object.keys(payload).length > 0
|
||||
? await request<Article>(`/v1/article/${articleId}?format=html`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
: undefined
|
||||
businessState.value = 'saved'
|
||||
savedAt.value = new Date()
|
||||
savedMeta.value = meta.value
|
||||
savedSeo.value = seoOf(saved, seo.value)
|
||||
seo.value = savedSeo.value
|
||||
bodyEdited.value = false
|
||||
clearStorage()
|
||||
return 'saved'
|
||||
} catch (err) {
|
||||
const rejected = rejectedOutcome(err)
|
||||
if (rejected) {
|
||||
businessState.value = 'dirty'
|
||||
return rejected
|
||||
}
|
||||
const gone = goneOutcome(err)
|
||||
if (gone) {
|
||||
businessState.value = 'error'
|
||||
@@ -212,5 +285,20 @@ export function useArticleDraft(articleId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
return { body, meta, state, savedAt, load, edit, editMeta, save }
|
||||
return {
|
||||
body,
|
||||
meta,
|
||||
seo,
|
||||
savedSeo,
|
||||
bodyEdited,
|
||||
mirrorToStorage,
|
||||
state,
|
||||
savedAt,
|
||||
load,
|
||||
edit,
|
||||
editMeta,
|
||||
editSeo,
|
||||
markDirty,
|
||||
save,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { Byline } from '@/lib/articleTypes'
|
||||
import type { ArticleStatus } from '@/lib/articleListQuery'
|
||||
|
||||
export type { Byline }
|
||||
|
||||
export interface Article {
|
||||
export interface Article extends Partial<ArticleSeo> {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MediaItem, MediaListResponse } from '@/composables/useMedia'
|
||||
|
||||
const LOOKUP_QUERY = 'page=1&size=100&sort=newest'
|
||||
|
||||
export function useMediaNames() {
|
||||
const { request } = useApi()
|
||||
const items = ref<Record<number, MediaItem>>({})
|
||||
|
||||
function remember(item: MediaItem) {
|
||||
items.value = { ...items.value, [item.id]: item }
|
||||
}
|
||||
|
||||
async function resolve(ids: (number | null | undefined)[]) {
|
||||
const wanted = ids.filter((id): id is number => typeof id === 'number')
|
||||
if (wanted.every((id) => id in items.value)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const page = await request<MediaListResponse>(`/v1/media/?${LOOKUP_QUERY}`)
|
||||
const found = (page?.items ?? []).filter((item) => wanted.includes(item.id))
|
||||
found.forEach(remember)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function itemOf(id: number | null | undefined) {
|
||||
return typeof id === 'number' ? (items.value[id] ?? null) : null
|
||||
}
|
||||
|
||||
return { itemOf, remember, resolve }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||
|
||||
export type ResolvedSeoPhase = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
export function useResolvedSeo(articleId: number) {
|
||||
const { request } = useApi()
|
||||
const phase = ref<ResolvedSeoPhase>('idle')
|
||||
const resolved = ref<ResolvedSeo | null>(null)
|
||||
let controller: AbortController | null = null
|
||||
let ticket = 0
|
||||
|
||||
async function load() {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const current = ++ticket
|
||||
phase.value = 'loading'
|
||||
try {
|
||||
const answer = await request<ResolvedSeo>(`/v1/article/${articleId}/seo`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (current === ticket) {
|
||||
resolved.value = answer
|
||||
phase.value = 'ready'
|
||||
}
|
||||
} catch {
|
||||
if (current === ticket) {
|
||||
phase.value = 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
controller?.abort()
|
||||
ticket += 1
|
||||
}
|
||||
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
return { phase, resolved, load, stop }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { SeoCheckBody, SeoCheckResult } from '@/lib/seoTypes'
|
||||
|
||||
export const SEO_CHECK_DEBOUNCE_MS = 500
|
||||
|
||||
export type SeoCheckPhase = 'idle' | 'pending' | 'ready' | 'failed'
|
||||
|
||||
export function isChecking(phase: SeoCheckPhase) {
|
||||
return phase === 'pending' || phase === 'idle'
|
||||
}
|
||||
|
||||
export interface SeoCheckOptions {
|
||||
articleId: number
|
||||
body: () => SeoCheckBody
|
||||
onGone: () => void
|
||||
onUnauthorized?: () => void
|
||||
}
|
||||
|
||||
export function useSeoCheck(options: SeoCheckOptions) {
|
||||
const { request } = useApi()
|
||||
const result = ref<SeoCheckResult | null>(null)
|
||||
const phase = ref<SeoCheckPhase>('idle')
|
||||
const checkedSlug = ref<string | undefined>(undefined)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let ticket = 0
|
||||
let lastPayload: string | null = null
|
||||
const answers = new Map<string, { result: SeoCheckResult; slug: string | undefined }>()
|
||||
|
||||
function clearTimer() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
clearTimer()
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
run()
|
||||
}, SEO_CHECK_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
async function send(body: SeoCheckBody, payload: string) {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const current = ++ticket
|
||||
phase.value = 'pending'
|
||||
try {
|
||||
const answer = await request<SeoCheckResult>(
|
||||
`/v1/article/${options.articleId}/seo/check`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
if (current !== ticket) {
|
||||
return
|
||||
}
|
||||
result.value = answer
|
||||
checkedSlug.value = body.slug
|
||||
phase.value = 'ready'
|
||||
answers.set(payload, { result: answer, slug: body.slug })
|
||||
} catch (err) {
|
||||
if (current !== ticket) {
|
||||
return
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
options.onGone()
|
||||
return
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
options.onUnauthorized?.()
|
||||
return
|
||||
}
|
||||
phase.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
function run(force = false) {
|
||||
clearTimer()
|
||||
const body = options.body()
|
||||
const payload = JSON.stringify(body)
|
||||
if (!force && payload === lastPayload && phase.value !== 'failed') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
lastPayload = payload
|
||||
return send(body, payload)
|
||||
}
|
||||
|
||||
function runNow() {
|
||||
return run(false)
|
||||
}
|
||||
|
||||
function runFresh() {
|
||||
return run(true)
|
||||
}
|
||||
|
||||
function restore() {
|
||||
const payload = JSON.stringify(options.body())
|
||||
const answer = answers.get(payload)
|
||||
clearTimer()
|
||||
controller?.abort()
|
||||
ticket += 1
|
||||
if (!answer) {
|
||||
lastPayload = null
|
||||
phase.value = result.value ? 'ready' : 'idle'
|
||||
return
|
||||
}
|
||||
lastPayload = payload
|
||||
result.value = answer.result
|
||||
checkedSlug.value = answer.slug
|
||||
phase.value = 'ready'
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
answers.clear()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearTimer()
|
||||
controller?.abort()
|
||||
ticket += 1
|
||||
}
|
||||
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
const status = computed(() => result.value?.status ?? null)
|
||||
const suggestions = computed(() => result.value?.suggestions ?? [])
|
||||
const slugVerdict = computed(() => result.value?.slug ?? null)
|
||||
|
||||
return {
|
||||
result,
|
||||
phase,
|
||||
status,
|
||||
suggestions,
|
||||
slugVerdict,
|
||||
checkedSlug,
|
||||
schedule,
|
||||
runNow,
|
||||
runFresh,
|
||||
restore,
|
||||
invalidate,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export interface Site {
|
||||
default_social_image_id: number | null
|
||||
default_robots: SiteRobots
|
||||
locale: string | null
|
||||
site_name?: string
|
||||
title_template?: string
|
||||
default_description?: string
|
||||
is_default: boolean
|
||||
article_count: number
|
||||
}
|
||||
|
||||
@@ -2003,6 +2003,135 @@ export const messages = {
|
||||
'Supprimer un auteur, en déplaçant ses articles avec reassign_to',
|
||||
'Create your own author profile, as any verified user':
|
||||
'Créer votre propre profil d’auteur, en tant qu’utilisateur vérifié',
|
||||
'Search and sharing': 'Recherche et partage',
|
||||
'Hidden from search': 'Masqué des recherches',
|
||||
'1 suggestion': '1 suggestion',
|
||||
'{0} suggestions': '{0} suggestions',
|
||||
Ready: 'Prêt',
|
||||
Address: 'Adresse',
|
||||
'Hidden from search engines. This only sets a flag in the API.':
|
||||
'Masqué des moteurs de recherche. Cela ne fait que positionner un indicateur dans l’API.',
|
||||
'Edit search and sharing': 'Modifier la recherche et le partage',
|
||||
'What the frontend gets': 'Ce que reçoit le frontend',
|
||||
'Checking this article…': 'Vérification de cet article…',
|
||||
"Couldn't check this article.": 'Impossible de vérifier cet article.',
|
||||
'All good': 'Tout est bon',
|
||||
'Nothing to suggest for this article.': 'Rien à suggérer pour cet article.',
|
||||
'+{0} more in Search and sharing': '+{0} de plus dans Recherche et partage',
|
||||
Suggestions: 'Suggestions',
|
||||
'The meta title is {0} characters. Search engines may cut it off after about {1}.':
|
||||
'Le titre meta fait {0} caractères. Les moteurs de recherche peuvent le couper après environ {1}.',
|
||||
'The meta title is empty.': 'Le titre meta est vide.',
|
||||
'The description is {0} characters. About 70 to 160 reads best in results.':
|
||||
'La description fait {0} caractères. Entre 70 et 160 environ se lit mieux dans les résultats.',
|
||||
'The cover image has no alt text. Add a short description of it.':
|
||||
'L’image de couverture n’a pas de texte alternatif. Ajoutez-en une courte description.',
|
||||
'This article has no cover image.': 'Cet article n’a pas d’image de couverture.',
|
||||
'The URL slug is {0} characters. Shorter addresses are easier to share.':
|
||||
'Le slug de l’URL fait {0} caractères. Les adresses plus courtes se partagent plus facilement.',
|
||||
'This article has no author.': 'Cet article n’a pas d’auteur.',
|
||||
'The body has no headings. H2 headings help readers scan the article.':
|
||||
'Le corps n’a pas de titres. Les titres H2 aident les lecteurs à parcourir l’article.',
|
||||
'1 image in the body has no alt text.': '1 image du corps n’a pas de texte alternatif.',
|
||||
'{0} images in the body have no alt text.':
|
||||
'{0} images du corps n’ont pas de texte alternatif.',
|
||||
'The body is {0} characters. Longer articles tend to do better in search.':
|
||||
'Le corps fait {0} caractères. Les articles plus longs ont tendance à mieux se classer.',
|
||||
'Go to meta title': 'Aller au titre meta',
|
||||
'Go to meta description': 'Aller à la description meta',
|
||||
'Go to cover image': 'Aller à l’image de couverture',
|
||||
'Go to URL slug': 'Aller au slug de l’URL',
|
||||
'Go to authors': 'Aller aux auteurs',
|
||||
'Go to body': 'Aller au corps',
|
||||
'URL slug': 'Slug de l’URL',
|
||||
'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on {0}.':
|
||||
'Généré à partir du titre tant que vous ne le modifiez pas. Lettres minuscules, chiffres et tirets. Unique sur {0}.',
|
||||
'Lowercase letters, numbers and hyphens. Unique on {0}.':
|
||||
'Lettres minuscules, chiffres et tirets. Unique sur {0}.',
|
||||
'Reset to match the title': 'Rétablir selon le titre',
|
||||
'Generated from the title': 'Généré à partir du titre',
|
||||
'The slug "{0}" is already used on {1}.': 'Le slug « {0} » est déjà utilisé sur {1}.',
|
||||
'Meta title': 'Titre meta',
|
||||
'Recommended 30 to 60 characters. This is a guide, not a limit.':
|
||||
'30 à 60 caractères recommandés. C’est un repère, pas une limite.',
|
||||
'Using article title': 'Titre de l’article utilisé',
|
||||
'Meta description': 'Description meta',
|
||||
'Recommended 70 to 160 characters.': '70 à 160 caractères recommandés.',
|
||||
'Using first sentence of the body': 'Première phrase du corps utilisée',
|
||||
'Using the excerpt': 'Extrait utilisé',
|
||||
'Using the site default description': 'Description par défaut du site utilisée',
|
||||
Excerpt: 'Extrait',
|
||||
'A short summary. It is also sent as the article teaser.':
|
||||
'Un court résumé. Il est aussi envoyé comme accroche de l’article.',
|
||||
'Used as the description when there is none, and as the teaser for the frontend.':
|
||||
'Utilisé comme description quand il n’y en a pas, et comme accroche pour le frontend.',
|
||||
'Shorten the excerpt to 300 characters or fewer.':
|
||||
'Raccourcissez l’extrait à 300 caractères ou moins.',
|
||||
'Cover image': 'Image de couverture',
|
||||
'Choose a cover image': 'Choisir une image de couverture',
|
||||
'The cover for this article.': 'La couverture de cet article.',
|
||||
'Using the site social image ({0}).': 'Image de partage du site utilisée ({0}).',
|
||||
'Using the site social image.': 'Image de partage du site utilisée.',
|
||||
'Alt text': 'Texte alternatif',
|
||||
"Describe the image for people who can't see it":
|
||||
'Décrivez l’image pour les personnes qui ne la voient pas',
|
||||
'Needed before the cover counts as ready. Sent as image alt in the API.':
|
||||
'Nécessaire avant que la couverture soit prête. Envoyé comme texte alternatif de l’image dans l’API.',
|
||||
'Social sharing': 'Partage social',
|
||||
Optional: 'Facultatif',
|
||||
'Uses the meta values': 'Utilise les valeurs meta',
|
||||
Customised: 'Personnalisé',
|
||||
'Social title': 'Titre social',
|
||||
'Using meta title': 'Titre meta utilisé',
|
||||
'Social description': 'Description sociale',
|
||||
'Using meta description': 'Description meta utilisée',
|
||||
'Social image': 'Image sociale',
|
||||
'Choose a social image': 'Choisir une image sociale',
|
||||
'The image shown when this article is shared.':
|
||||
'L’image affichée quand cet article est partagé.',
|
||||
'Using the cover image.': 'Image de couverture utilisée.',
|
||||
Advanced: 'Avancé',
|
||||
'Canonical URL': 'URL canonique',
|
||||
'Canonical URL: automatic': 'URL canonique : automatique',
|
||||
'Canonical URL set': 'URL canonique définie',
|
||||
'Using site address and slug': 'Adresse du site et slug utilisés',
|
||||
'Only change this if the same content lives at another address.':
|
||||
'Ne modifiez ceci que si le même contenu existe à une autre adresse.',
|
||||
'Enter a full address starting with https://':
|
||||
'Saisissez une adresse complète commençant par https://',
|
||||
'Hide from search engines': 'Masquer des moteurs de recherche',
|
||||
"Sets noindex in the API. It doesn't change who can read the article.":
|
||||
'Positionne noindex dans l’API. Cela ne change pas qui peut lire l’article.',
|
||||
'Search result': 'Résultat de recherche',
|
||||
Desktop: 'Ordinateur',
|
||||
Mobile: 'Mobile',
|
||||
'Cut off here, the way a search engine would.':
|
||||
'Coupé ici, comme le ferait un moteur de recherche.',
|
||||
'Social card': 'Carte sociale',
|
||||
'Using cover image': 'Image de couverture utilisée',
|
||||
'Using site social image': 'Image de partage du site utilisée',
|
||||
'No image': 'Aucune image',
|
||||
Formatted: 'Formaté',
|
||||
'Raw JSON': 'JSON brut',
|
||||
'Copy JSON': 'Copier le JSON',
|
||||
"The SEO data returned by {0}, read only. RenewCMS doesn't render pages or add meta tags: your frontend does.":
|
||||
'Les données SEO renvoyées par {0}, en lecture seule. RenewCMS n’affiche pas de pages et n’ajoute pas de balises meta : c’est votre frontend qui le fait.',
|
||||
'This article is a draft. The API returns this data only to signed-in callers until it is published.':
|
||||
'Cet article est un brouillon. L’API ne renvoie ces données qu’aux appelants connectés tant qu’il n’est pas publié.',
|
||||
"Couldn't load the SEO data": 'Impossible de charger les données SEO',
|
||||
'{0} failed. Your article is unchanged. Try again in a moment.':
|
||||
'{0} a échoué. Votre article est inchangé. Réessayez dans un instant.',
|
||||
'1 social link': '1 lien social',
|
||||
'{0} social links': '{0} liens sociaux',
|
||||
"Couldn't save search and sharing. Your changes are still here. Try again in a moment.":
|
||||
'Impossible d’enregistrer la recherche et le partage. Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
'Search and sharing saved': 'Recherche et partage enregistrés',
|
||||
'Your search and sharing settings are up to date.':
|
||||
'Vos réglages de recherche et de partage sont à jour.',
|
||||
'Suggestions never block saving or publishing.':
|
||||
'Les suggestions ne bloquent jamais l’enregistrement ni la publication.',
|
||||
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.":
|
||||
'Décrit l’image pour les personnes qui utilisent un lecteur d’écran, et s’affiche si elle ne charge pas. Laissez vide si l’image est purement décorative.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,193 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale, setLocale, t } from './index'
|
||||
import { messages } from './messages'
|
||||
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||
import { apiPreviewRows } from '@/lib/seoApiPreview'
|
||||
import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ApiPreviewDialog.vue',
|
||||
'components/FallbackChip.vue',
|
||||
'components/SearchPreview.vue',
|
||||
'components/SearchSharingSection.vue',
|
||||
'components/SeoDisclosure.vue',
|
||||
'components/SeoPanel.vue',
|
||||
'components/SocialCardPreview.vue',
|
||||
'components/SoftCounter.vue',
|
||||
'components/SuggestionList.vue',
|
||||
'lib/seoApiPreview.ts',
|
||||
'lib/seoCopy.ts',
|
||||
'lib/seoPreview.ts',
|
||||
'views/EditorView.vue',
|
||||
]
|
||||
|
||||
const SAME_IN_FRENCH = new Set([
|
||||
'Articles',
|
||||
'1 suggestion',
|
||||
'{0} suggestions',
|
||||
'Suggestions',
|
||||
'Mobile',
|
||||
])
|
||||
|
||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||
|
||||
function keysIn(source: string) {
|
||||
return [...source.matchAll(LITERAL)].map((match) =>
|
||||
(match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'),
|
||||
)
|
||||
}
|
||||
|
||||
const keys = SOURCES.flatMap((file) =>
|
||||
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||
file,
|
||||
key,
|
||||
})),
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Search and sharing: every string has a French entry', () => {
|
||||
it('finds the strings of the section, the panel, the dialog and the previews', () => {
|
||||
expect(keys.length).toBeGreaterThan(90)
|
||||
})
|
||||
|
||||
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||
'%s: "%s" has its own French entry',
|
||||
(_file, key) => {
|
||||
const french = (messages as Record<string, string>)[key as string]
|
||||
expect(french).toMatch(/\S/)
|
||||
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||
expect(french).not.toBe(key)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Search and sharing in French', () => {
|
||||
const codes: SuggestionCode[] = [
|
||||
'title_long',
|
||||
'title_empty',
|
||||
'desc_short',
|
||||
'desc_long',
|
||||
'cover_alt',
|
||||
'cover_none',
|
||||
'slug_long',
|
||||
'no_au',
|
||||
'no_h',
|
||||
'body_alt',
|
||||
'short',
|
||||
]
|
||||
|
||||
function suggestionOf(code: SuggestionCode): SeoSuggestion {
|
||||
return {
|
||||
code,
|
||||
field: 'body',
|
||||
params: { length: 74, max: 60, min: 70, count: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
it('translates the messages of the 11 suggestion codes', () => {
|
||||
const english = codes.map((code) => suggestionMessage(suggestionOf(code)))
|
||||
setLocale('fr')
|
||||
const french = codes.map((code) => suggestionMessage(suggestionOf(code)))
|
||||
|
||||
french.forEach((message, index) => {
|
||||
expect(message).not.toBe(english[index])
|
||||
expect(message).not.toContain('{0}')
|
||||
})
|
||||
expect(french[0]).toBe(
|
||||
'Le titre meta fait 74 caractères. Les moteurs de recherche peuvent le couper après environ 60.',
|
||||
)
|
||||
})
|
||||
|
||||
it('translates the singular and the plural body_alt message', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(suggestionMessage({ code: 'body_alt', field: 'body', params: { count: 1 } })).toBe(
|
||||
'1 image du corps n’a pas de texte alternatif.',
|
||||
)
|
||||
expect(suggestionMessage({ code: 'body_alt', field: 'body', params: { count: 2 } })).toBe(
|
||||
'2 images du corps n’ont pas de texte alternatif.',
|
||||
)
|
||||
})
|
||||
|
||||
it('translates the Go to labels of every field', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(
|
||||
(
|
||||
[
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
'cover_image_alt',
|
||||
'cover_image_id',
|
||||
'slug',
|
||||
'author_ids',
|
||||
'body',
|
||||
] as const
|
||||
).map(suggestionLinkLabel),
|
||||
).toEqual([
|
||||
'Aller au titre meta',
|
||||
'Aller à la description meta',
|
||||
'Aller à l’image de couverture',
|
||||
'Aller à l’image de couverture',
|
||||
'Aller au slug de l’URL',
|
||||
'Aller aux auteurs',
|
||||
'Aller au corps',
|
||||
])
|
||||
})
|
||||
|
||||
it('translates the chips and the dates of the dialog', () => {
|
||||
setLocale('fr')
|
||||
const resolved = {
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
canonical_url: 'https://renew.example/t',
|
||||
robots: 'index, follow',
|
||||
og: { type: 'article', image: 'u', image_alt: 'a' },
|
||||
twitter: { card: 'summary' },
|
||||
authors: [{ name: 'Inès', url: '', same_as: ['a', 'b'] }],
|
||||
published_at: '2026-03-12T09:14:00Z',
|
||||
modified_at: '2026-10-15T11:02:00Z',
|
||||
fallbacks: { title: 'title', description: 'excerpt', image: 'site', canonical: 'site' },
|
||||
json_ld: { '@type': 'Article' },
|
||||
} as unknown as ResolvedSeo
|
||||
|
||||
const rows = apiPreviewRows(resolved)
|
||||
|
||||
expect(rows.map((row) => row.chip)).toEqual([
|
||||
'Titre de l’article utilisé',
|
||||
'Extrait utilisé',
|
||||
'Adresse du site et slug utilisés',
|
||||
null,
|
||||
null,
|
||||
'Image de partage du site utilisée',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
])
|
||||
expect(rows.find((row) => row.key === 'authors')!.value).toBe('Inès · 2 liens sociaux')
|
||||
})
|
||||
|
||||
it('translates the section strings', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(t('Search and sharing')).toBe('Recherche et partage')
|
||||
expect(t('Edit search and sharing')).toBe('Modifier la recherche et le partage')
|
||||
expect(t('What the frontend gets')).toBe('Ce que reçoit le frontend')
|
||||
expect(t('Search and sharing saved')).toBe('Recherche et partage enregistrés')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isChecking } from '@/composables/useSeoCheck'
|
||||
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||
import type { SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
function suggestion(code: string, params: Record<string, number> = {}): SeoSuggestion {
|
||||
return { code: code as SuggestionCode, field: 'body', params }
|
||||
}
|
||||
|
||||
describe('suggestionMessage', () => {
|
||||
it('uses the same wording for short and long descriptions with the length filled in', () => {
|
||||
const short = suggestionMessage(suggestion('desc_short', { length: 12 }))
|
||||
const long = suggestionMessage(suggestion('desc_long', { length: 200 }))
|
||||
|
||||
expect(short).toContain('12')
|
||||
expect(long).toContain('200')
|
||||
expect(short.replace('12', '#')).toBe(long.replace('200', '#'))
|
||||
})
|
||||
|
||||
it('switches between the singular and plural body image wording', () => {
|
||||
const one = suggestionMessage(suggestion('body_alt', { count: 1 }))
|
||||
const many = suggestionMessage(suggestion('body_alt', { count: 3 }))
|
||||
|
||||
expect(one).not.toBe(many)
|
||||
expect(many).toContain('3')
|
||||
expect(one).not.toContain('1 images')
|
||||
})
|
||||
|
||||
it('falls back to the raw code for an unknown code', () => {
|
||||
expect(suggestionMessage(suggestion('brand_new'))).toBe('brand_new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestionLinkLabel', () => {
|
||||
it('returns an empty label for an unknown field', () => {
|
||||
expect(suggestionLinkLabel('nope' as never)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isChecking', () => {
|
||||
it.each([
|
||||
['idle', true],
|
||||
['pending', true],
|
||||
['ready', false],
|
||||
['failed', false],
|
||||
] as const)('%s is %s', (phase, expected) => {
|
||||
expect(isChecking(phase)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EMPTY_SEO } from '@/lib/articleSeo'
|
||||
import {
|
||||
applyTitleTemplate,
|
||||
bodyText,
|
||||
firstSentence,
|
||||
previewTitle,
|
||||
siteDisplayName,
|
||||
socialTitle,
|
||||
} from '@/lib/seoPreview'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
const site = {
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index',
|
||||
locale: null,
|
||||
site_name: '',
|
||||
title_template: '%title% | %site%',
|
||||
is_default: true,
|
||||
article_count: 1,
|
||||
} as Site
|
||||
|
||||
describe('applyTitleTemplate', () => {
|
||||
it('replaces every occurrence in a single pass', () => {
|
||||
expect(applyTitleTemplate('%site%: %title% (%title%)', 'A', 'S')).toBe('S: A (A)')
|
||||
})
|
||||
|
||||
it('does not expand replacement patterns or substitute tokens inside the title', () => {
|
||||
expect(applyTitleTemplate('%title% | %site%', "Cost $& $' $` %site%", 'Renew')).toBe(
|
||||
"Cost $& $' $` %site% | Renew",
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the default template and trims', () => {
|
||||
expect(applyTitleTemplate('', 'A', '')).toBe('A |')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bodyText', () => {
|
||||
it('separates block elements with a space', () => {
|
||||
expect(bodyText('<p>One.</p><p>Two</p>')).toBe('One. Two')
|
||||
expect(firstSentence('<p>One.</p><p>Two</p>')).toBe('One.')
|
||||
})
|
||||
|
||||
it('skips script and style text', () => {
|
||||
expect(bodyText('<style>p{}</style><p>Kept</p><script>alert(1)</script>')).toBe('Kept')
|
||||
})
|
||||
|
||||
it('does not run markup', () => {
|
||||
const img = '<img src=x onerror="document.body.dataset.ran=1">'
|
||||
bodyText(img)
|
||||
expect(document.body.dataset.ran).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('titles', () => {
|
||||
const input = { seo: { ...EMPTY_SEO }, title: 'Article', body: '', site }
|
||||
|
||||
it('uses the site name, then the site key name, for the template', () => {
|
||||
expect(siteDisplayName(site)).toBe('Renew')
|
||||
expect(previewTitle(input)).toBe('Article | Renew')
|
||||
})
|
||||
|
||||
it('falls back to the templated title for the social title', () => {
|
||||
expect(socialTitle(input)).toBe('Article | Renew')
|
||||
expect(socialTitle({ ...input, seo: { ...EMPTY_SEO, seo_title: 'Meta' } })).toBe(
|
||||
'Meta | Renew',
|
||||
)
|
||||
expect(socialTitle({ ...input, seo: { ...EMPTY_SEO, social_title: 'Own' } })).toBe('Own')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
export const SEO_KEYS = [
|
||||
'slug',
|
||||
'excerpt',
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
'cover_image_id',
|
||||
'cover_image_alt',
|
||||
'social_title',
|
||||
'social_description',
|
||||
'social_image_id',
|
||||
'canonical_url',
|
||||
'noindex',
|
||||
] as const
|
||||
|
||||
export type SeoKey = (typeof SEO_KEYS)[number]
|
||||
|
||||
export interface ArticleSeo {
|
||||
slug: string
|
||||
excerpt: string
|
||||
seo_title: string
|
||||
seo_description: string
|
||||
cover_image_id: number | null
|
||||
cover_image_alt: string
|
||||
social_title: string
|
||||
social_description: string
|
||||
social_image_id: number | null
|
||||
canonical_url: string
|
||||
noindex: boolean
|
||||
}
|
||||
|
||||
export type SeoPatch = Partial<ArticleSeo>
|
||||
|
||||
export const EMPTY_SEO: ArticleSeo = {
|
||||
slug: '',
|
||||
excerpt: '',
|
||||
seo_title: '',
|
||||
seo_description: '',
|
||||
cover_image_id: null,
|
||||
cover_image_alt: '',
|
||||
social_title: '',
|
||||
social_description: '',
|
||||
social_image_id: null,
|
||||
canonical_url: '',
|
||||
noindex: false,
|
||||
}
|
||||
|
||||
export const SEO_LIMITS = {
|
||||
slug: 100,
|
||||
excerpt: 300,
|
||||
seo_title: 120,
|
||||
seo_description: 320,
|
||||
cover_image_alt: 200,
|
||||
social_title: 120,
|
||||
social_description: 320,
|
||||
canonical_url: 2048,
|
||||
}
|
||||
|
||||
const NON_SLUG_RUN = /[^a-z0-9]+/g
|
||||
const EDGE_DASHES = /^-+|-+$/g
|
||||
|
||||
const IMAGE_KEYS: SeoKey[] = ['cover_image_id', 'social_image_id']
|
||||
|
||||
function hasExpectedType(key: SeoKey, value: unknown) {
|
||||
if (IMAGE_KEYS.includes(key)) {
|
||||
return value === null || typeof value === 'number'
|
||||
}
|
||||
return typeof value === (key === 'noindex' ? 'boolean' : 'string')
|
||||
}
|
||||
|
||||
export function seoOf(source: object | null | undefined, base: ArticleSeo = EMPTY_SEO): ArticleSeo {
|
||||
const known = (source ?? {}) as Record<string, unknown>
|
||||
const next: Record<string, unknown> = { ...base }
|
||||
for (const key of SEO_KEYS) {
|
||||
if (key in known && hasExpectedType(key, known[key])) {
|
||||
next[key] = known[key]
|
||||
}
|
||||
}
|
||||
return next as unknown as ArticleSeo
|
||||
}
|
||||
|
||||
export function seoChanges(current: ArticleSeo, saved: ArticleSeo): SeoPatch {
|
||||
const changes: Record<string, unknown> = {}
|
||||
for (const key of SEO_KEYS) {
|
||||
if (current[key] !== saved[key]) {
|
||||
changes[key] = current[key]
|
||||
}
|
||||
}
|
||||
return changes as SeoPatch
|
||||
}
|
||||
|
||||
export function sameSeo(left: ArticleSeo, right: ArticleSeo) {
|
||||
return Object.keys(seoChanges(left, right)).length === 0
|
||||
}
|
||||
|
||||
export function charCount(text: string) {
|
||||
return [...text].length
|
||||
}
|
||||
|
||||
export function normalizeSlug(raw: string) {
|
||||
return raw.toLowerCase().replace(NON_SLUG_RUN, '-').replace(EDGE_DASHES, '')
|
||||
}
|
||||
|
||||
export function typedSlug(raw: string) {
|
||||
return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-')
|
||||
}
|
||||
|
||||
export function isAbsoluteHttpsUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'https:' && url.hostname !== ''
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalIsValid(value: string) {
|
||||
return value.trim() === '' || isAbsoluteHttpsUrl(value.trim())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function booleanMemory(key: string) {
|
||||
function read() {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw === 'true' ? true : raw === 'false' ? false : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function store(open: boolean) {
|
||||
try {
|
||||
localStorage.setItem(key, String(open))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return { read, store }
|
||||
}
|
||||
@@ -2,5 +2,6 @@ export interface ImageChoice {
|
||||
title: string
|
||||
description: string
|
||||
hint?: string
|
||||
square?: boolean
|
||||
confirmLabel: string
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
import { booleanMemory } from '@/lib/booleanMemory'
|
||||
|
||||
export const META_SECTION_OPEN_KEY = 'renewcms-meta-section-open'
|
||||
const HINT_DISMISSED_PREFIX = 'renewcms-author-hint-dismissed'
|
||||
|
||||
export function readMetaSectionOpen() {
|
||||
try {
|
||||
const raw = localStorage.getItem(META_SECTION_OPEN_KEY)
|
||||
return raw === 'true' ? true : raw === 'false' ? false : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const memory = booleanMemory(META_SECTION_OPEN_KEY)
|
||||
|
||||
export function storeMetaSectionOpen(open: boolean) {
|
||||
try {
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, String(open))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
export const readMetaSectionOpen = memory.read
|
||||
export const storeMetaSectionOpen = memory.store
|
||||
|
||||
export function hintDismissedKey(userId: number | undefined) {
|
||||
return `${HINT_DISMISSED_PREFIX}:${userId ?? 'anonymous'}`
|
||||
|
||||
@@ -13,3 +13,6 @@ export function segmentClass(selected: boolean, block = false) {
|
||||
}
|
||||
|
||||
export const SEGMENT_GROUP = 'flex shrink-0 border border-accented'
|
||||
|
||||
export const SEGMENT_TAB_ACTIVE =
|
||||
'data-[state=active]:bg-secondary-200 data-[state=active]:font-medium data-[state=active]:text-primary-900 dark:data-[state=active]:bg-secondary-300'
|
||||
@@ -0,0 +1,105 @@
|
||||
import { t, locale } from '@/i18n'
|
||||
import { chipLabel, descriptionChip, type ChipKey } from '@/lib/seoCopy'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
]
|
||||
const EMPTY_VALUE = '–'
|
||||
|
||||
export interface ApiPreviewRow {
|
||||
key: string
|
||||
value: string
|
||||
chip: string | null
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return value.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
export function formatApiDate(iso: string | null) {
|
||||
if (!iso) {
|
||||
return EMPTY_VALUE
|
||||
}
|
||||
const date = new Date(iso)
|
||||
const clock = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
return locale.value === 'fr'
|
||||
? `${formatAbsoluteDateFromIso(iso)}, ${clock}`
|
||||
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${clock}`
|
||||
}
|
||||
|
||||
function titleChip(source: string) {
|
||||
return source === 'title' ? chipLabel('article_title') : null
|
||||
}
|
||||
|
||||
function imageChip(source: string) {
|
||||
const keys: Record<string, ChipKey> = {
|
||||
cover: 'cover_image',
|
||||
site: 'site_image',
|
||||
none: 'no_image',
|
||||
}
|
||||
const key = keys[source]
|
||||
return key ? chipLabel(key) : null
|
||||
}
|
||||
|
||||
function canonicalChip(source: string) {
|
||||
return source === 'site' ? chipLabel('canonical') : null
|
||||
}
|
||||
|
||||
function authorLine(author: ResolvedSeo['authors'][number]) {
|
||||
const links = author.same_as.length
|
||||
return [
|
||||
author.name,
|
||||
author.url ? author.url.replace(/^https?:\/\//, '') : '',
|
||||
links === 0 ? '' : links === 1 ? t('1 social link') : t('{0} social links', [links]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
function jsonLdSummary(jsonLd: Record<string, unknown>) {
|
||||
const fields = Object.keys(jsonLd).filter((key) => !key.startsWith('@'))
|
||||
return [String(jsonLd['@type'] ?? ''), fields.join(', ')].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
export function apiPreviewRows(resolved: ResolvedSeo): ApiPreviewRow[] {
|
||||
const { fallbacks } = resolved
|
||||
return [
|
||||
{ key: 'title', value: resolved.title, chip: titleChip(fallbacks.title) },
|
||||
{
|
||||
key: 'description',
|
||||
value: resolved.description,
|
||||
chip: descriptionChip(fallbacks.description),
|
||||
},
|
||||
{
|
||||
key: 'canonical_url',
|
||||
value: resolved.canonical_url,
|
||||
chip: canonicalChip(fallbacks.canonical),
|
||||
},
|
||||
{ key: 'robots', value: resolved.robots, chip: null },
|
||||
{ key: 'og.type', value: resolved.og.type, chip: null },
|
||||
{ key: 'og.image', value: resolved.og.image, chip: imageChip(fallbacks.image) },
|
||||
{ key: 'og.image_alt', value: resolved.og.image_alt, chip: null },
|
||||
{ key: 'twitter.card', value: resolved.twitter.card, chip: null },
|
||||
{
|
||||
key: 'authors',
|
||||
value: resolved.authors.map(authorLine).join('; ') || EMPTY_VALUE,
|
||||
chip: null,
|
||||
},
|
||||
{ key: 'published_at', value: formatApiDate(resolved.published_at), chip: null },
|
||||
{ key: 'modified_at', value: formatApiDate(resolved.modified_at), chip: null },
|
||||
{ key: 'json_ld', value: jsonLdSummary(resolved.json_ld), chip: null },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { DescriptionSource } from '@/lib/seoPreview'
|
||||
import type { SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||
|
||||
function descriptionLength(params: Record<string, number>) {
|
||||
return t('The description is {0} characters. About 70 to 160 reads best in results.', [
|
||||
params.length,
|
||||
])
|
||||
}
|
||||
|
||||
const MESSAGES: Record<SuggestionCode, (params: Record<string, number>) => string> = {
|
||||
title_long: (params) =>
|
||||
t('The meta title is {0} characters. Search engines may cut it off after about {1}.', [
|
||||
params.length,
|
||||
params.max,
|
||||
]),
|
||||
title_empty: () => t('The meta title is empty.'),
|
||||
desc_short: descriptionLength,
|
||||
desc_long: descriptionLength,
|
||||
cover_alt: () => t('The cover image has no alt text. Add a short description of it.'),
|
||||
cover_none: () => t('This article has no cover image.'),
|
||||
slug_long: (params) =>
|
||||
t('The URL slug is {0} characters. Shorter addresses are easier to share.', [
|
||||
params.length,
|
||||
]),
|
||||
no_au: () => t('This article has no author.'),
|
||||
no_h: () => t('The body has no headings. H2 headings help readers scan the article.'),
|
||||
body_alt: (params) =>
|
||||
params.count === 1
|
||||
? t('1 image in the body has no alt text.')
|
||||
: t('{0} images in the body have no alt text.', [params.count]),
|
||||
short: (params) =>
|
||||
t('The body is {0} characters. Longer articles tend to do better in search.', [
|
||||
params.length,
|
||||
]),
|
||||
}
|
||||
|
||||
const LINK_LABELS: Record<SuggestionField, () => string> = {
|
||||
seo_title: () => t('Go to meta title'),
|
||||
seo_description: () => t('Go to meta description'),
|
||||
cover_image_alt: () => t('Go to cover image'),
|
||||
cover_image_id: () => t('Go to cover image'),
|
||||
slug: () => t('Go to URL slug'),
|
||||
author_ids: () => t('Go to authors'),
|
||||
body: () => t('Go to body'),
|
||||
}
|
||||
|
||||
export function suggestionMessage(suggestion: SeoSuggestion) {
|
||||
const message = MESSAGES[suggestion.code]
|
||||
return message ? message(suggestion.params ?? {}) : suggestion.code
|
||||
}
|
||||
|
||||
export function suggestionLinkLabel(field: SuggestionField) {
|
||||
const label = LINK_LABELS[field]
|
||||
return label ? label() : ''
|
||||
}
|
||||
|
||||
const CHIPS = {
|
||||
article_title: () => t('Using article title'),
|
||||
body: () => t('Using first sentence of the body'),
|
||||
excerpt: () => t('Using the excerpt'),
|
||||
site_description: () => t('Using the site default description'),
|
||||
cover_image: () => t('Using cover image'),
|
||||
site_image: () => t('Using site social image'),
|
||||
no_image: () => t('No image'),
|
||||
canonical: () => t('Using site address and slug'),
|
||||
meta_title: () => t('Using meta title'),
|
||||
meta_description: () => t('Using meta description'),
|
||||
}
|
||||
|
||||
export type ChipKey = keyof typeof CHIPS
|
||||
|
||||
export function chipLabel(key: ChipKey) {
|
||||
return CHIPS[key]()
|
||||
}
|
||||
|
||||
export function descriptionChip(source: DescriptionSource | string) {
|
||||
const keys: Record<string, ChipKey> = {
|
||||
excerpt: 'excerpt',
|
||||
body: 'body',
|
||||
site: 'site_description',
|
||||
}
|
||||
const key = keys[source]
|
||||
return key ? chipLabel(key) : null
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { charCount } from '@/lib/articleSeo'
|
||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
export type PreviewDevice = 'desktop' | 'mobile'
|
||||
|
||||
export const PREVIEW_LIMITS: Record<PreviewDevice, { title: number; description: number }> = {
|
||||
desktop: { title: 60, description: 160 },
|
||||
mobile: { title: 70, description: 120 },
|
||||
}
|
||||
|
||||
export const TITLE_RANGE = { min: 30, max: 60 }
|
||||
export const DESCRIPTION_RANGE = { min: 70, max: 160 }
|
||||
export const SOCIAL_TITLE_RANGE = { min: 0, max: 90 }
|
||||
|
||||
const DEFAULT_TEMPLATE = '%title% | %site%'
|
||||
const SENTENCE_END = /^(.+?[.!?])(\s|$)/s
|
||||
|
||||
export type TitleSource = 'seo_title' | 'title'
|
||||
export type DescriptionSource = 'seo_description' | 'excerpt' | 'body' | 'site' | 'none'
|
||||
|
||||
export function truncateText(text: string, max: number) {
|
||||
const characters = [...text]
|
||||
return characters.length > max
|
||||
? `${characters
|
||||
.slice(0, max - 1)
|
||||
.join('')
|
||||
.trimEnd()}…`
|
||||
: text
|
||||
}
|
||||
|
||||
const BLOCK_BOUNDARY = /<\/?(p|div|h[1-6]|li|ul|ol|blockquote|pre|tr|br|hr)\b[^>]*>/gi
|
||||
const SKIPPED_ELEMENTS = 'script, style'
|
||||
|
||||
export function bodyText(html: string) {
|
||||
const parsed = new DOMParser().parseFromString(
|
||||
html.replace(BLOCK_BOUNDARY, ' $& '),
|
||||
'text/html',
|
||||
)
|
||||
parsed.querySelectorAll(SKIPPED_ELEMENTS).forEach((element) => element.remove())
|
||||
return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function firstSentence(html: string) {
|
||||
const text = bodyText(html)
|
||||
const match = SENTENCE_END.exec(text)
|
||||
return match ? match[1]! : text
|
||||
}
|
||||
|
||||
const TEMPLATE_TOKEN = /%title%|%site%/g
|
||||
|
||||
export function applyTitleTemplate(template: string, title: string, siteName: string) {
|
||||
return (template || DEFAULT_TEMPLATE)
|
||||
.replace(TEMPLATE_TOKEN, (token) => (token === '%title%' ? title : siteName))
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function siteDisplayName(site: Site | undefined) {
|
||||
return site?.site_name || site?.name || ''
|
||||
}
|
||||
|
||||
export interface SeoPreviewInput {
|
||||
seo: ArticleSeo
|
||||
title: string
|
||||
body: string
|
||||
site: Site | undefined
|
||||
}
|
||||
|
||||
export function previewTitleSource(input: SeoPreviewInput): TitleSource {
|
||||
return input.seo.seo_title.trim() ? 'seo_title' : 'title'
|
||||
}
|
||||
|
||||
export function previewTitle(input: SeoPreviewInput) {
|
||||
const base = input.seo.seo_title.trim() || input.title
|
||||
return applyTitleTemplate(input.site?.title_template ?? '', base, siteDisplayName(input.site))
|
||||
}
|
||||
|
||||
export function previewDescriptionSource(input: SeoPreviewInput): DescriptionSource {
|
||||
if (input.seo.seo_description.trim()) {
|
||||
return 'seo_description'
|
||||
}
|
||||
if (input.seo.excerpt.trim()) {
|
||||
return 'excerpt'
|
||||
}
|
||||
if (firstSentence(input.body)) {
|
||||
return 'body'
|
||||
}
|
||||
return input.site?.default_description ? 'site' : 'none'
|
||||
}
|
||||
|
||||
export function previewDescription(input: SeoPreviewInput) {
|
||||
const sources: Record<DescriptionSource, string> = {
|
||||
seo_description: input.seo.seo_description.trim(),
|
||||
excerpt: input.seo.excerpt.trim(),
|
||||
body: firstSentence(input.body),
|
||||
site: input.site?.default_description ?? '',
|
||||
none: '',
|
||||
}
|
||||
return sources[previewDescriptionSource(input)]
|
||||
}
|
||||
|
||||
export function titleIsCut(title: string, device: PreviewDevice) {
|
||||
return charCount(title) > PREVIEW_LIMITS[device].title
|
||||
}
|
||||
|
||||
export function socialTitle(input: SeoPreviewInput) {
|
||||
return input.seo.social_title.trim() || previewTitle(input)
|
||||
}
|
||||
|
||||
export function socialDescription(input: SeoPreviewInput) {
|
||||
return input.seo.social_description.trim() || previewDescription(input)
|
||||
}
|
||||
|
||||
export function hasSocialOverrides(seo: ArticleSeo) {
|
||||
return Boolean(seo.social_title || seo.social_description || seo.social_image_id !== null)
|
||||
}
|
||||
|
||||
export type SocialImageSource = 'social' | 'cover' | 'site' | 'none'
|
||||
|
||||
export function socialImageSource(seo: ArticleSeo, siteHasImage: boolean): SocialImageSource {
|
||||
if (seo.social_image_id !== null) {
|
||||
return 'social'
|
||||
}
|
||||
if (seo.cover_image_id !== null) {
|
||||
return 'cover'
|
||||
}
|
||||
return siteHasImage ? 'site' : 'none'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { booleanMemory } from '@/lib/booleanMemory'
|
||||
|
||||
export const SEO_SECTION_OPEN_KEY = 'renewcms-seo-section-open'
|
||||
|
||||
const memory = booleanMemory(SEO_SECTION_OPEN_KEY)
|
||||
|
||||
export const readSeoSectionOpen = memory.read
|
||||
export const storeSeoSectionOpen = memory.store
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { SeoPatch } from '@/lib/articleSeo'
|
||||
|
||||
export type SeoCheckStatus = 'ready' | 'suggestions' | 'hidden'
|
||||
|
||||
export type SuggestionCode =
|
||||
| 'title_long'
|
||||
| 'title_empty'
|
||||
| 'desc_short'
|
||||
| 'desc_long'
|
||||
| 'cover_alt'
|
||||
| 'cover_none'
|
||||
| 'slug_long'
|
||||
| 'no_au'
|
||||
| 'no_h'
|
||||
| 'body_alt'
|
||||
| 'short'
|
||||
|
||||
export type SuggestionField =
|
||||
| 'seo_title'
|
||||
| 'seo_description'
|
||||
| 'cover_image_alt'
|
||||
| 'cover_image_id'
|
||||
| 'slug'
|
||||
| 'author_ids'
|
||||
| 'body'
|
||||
|
||||
export interface SeoSuggestion {
|
||||
code: SuggestionCode
|
||||
field: SuggestionField
|
||||
params: Record<string, number>
|
||||
}
|
||||
|
||||
export interface SeoCheckResult {
|
||||
status: SeoCheckStatus
|
||||
suggestions: SeoSuggestion[]
|
||||
slug: { available: boolean; suggestion: string | null }
|
||||
}
|
||||
|
||||
export type SeoCheckBody = SeoPatch & { body?: string }
|
||||
|
||||
export type SeoFocus = SuggestionField | 'suggestions'
|
||||
|
||||
export interface ResolvedSeo {
|
||||
title: string
|
||||
description: string
|
||||
canonical_url: string
|
||||
robots: string
|
||||
og: {
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
image_alt: string
|
||||
url: string
|
||||
site_name: string
|
||||
locale: string
|
||||
}
|
||||
twitter: {
|
||||
card: string
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
image_alt: string
|
||||
}
|
||||
image: { url: string; alt: string; source: string }
|
||||
authors: { name: string; url: string; same_as: string[] }[]
|
||||
published_at: string | null
|
||||
modified_at: string
|
||||
fallbacks: { title: string; description: string; image: string; canonical: string }
|
||||
json_ld: Record<string, unknown>
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { createReusableTemplate } from '@vueuse/core'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
@@ -11,6 +11,9 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import ArticleDetails from '@/components/ArticleDetails.vue'
|
||||
import MetaSection from '@/components/MetaSection.vue'
|
||||
import SearchSharingSection from '@/components/SearchSharingSection.vue'
|
||||
import SeoPanel from '@/components/SeoPanel.vue'
|
||||
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
|
||||
import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
|
||||
@@ -21,18 +24,26 @@ import { usePageTitleOverride } from '@/composables/documentTitle'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleActions } from '@/composables/useArticleActions'
|
||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
|
||||
import { useSeoCheck } from '@/composables/useSeoCheck'
|
||||
import { useEditorImages } from '@/composables/useEditorImages'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import { toBylines } from '@/lib/articleMeta'
|
||||
import { readMetaSectionOpen, storeMetaSectionOpen } from '@/lib/metaSectionMemory'
|
||||
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 { bodyCounts } from '@/lib/bodyCounts'
|
||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
||||
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const FOCUS_RETRIES = 10
|
||||
const FOCUS_RETRY_MS = 30
|
||||
|
||||
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
||||
|
||||
@@ -73,6 +84,16 @@ const detailsOpen = ref(false)
|
||||
const publishPending = ref<string | undefined>(undefined)
|
||||
const sites = ref<Site[]>([])
|
||||
const remembered = ref<boolean | null>(readMetaSectionOpen())
|
||||
const seoRemembered = ref<boolean | null>(readSeoSectionOpen())
|
||||
const panelOpen = ref(false)
|
||||
const panelValues = ref<ArticleSeo | null>(null)
|
||||
const panelFocus = ref<SeoFocus | null>(null)
|
||||
const panelNotice = ref<DirectoryNotice | null>(null)
|
||||
const panelSaving = ref(false)
|
||||
const slugTakenOnSave = ref<string | null>(null)
|
||||
const canonicalRejected = ref(false)
|
||||
const apiPreviewOpen = ref(false)
|
||||
let panelSaveFailed = false
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
@@ -84,7 +105,8 @@ function clearSkeletonTimer() {
|
||||
|
||||
async function fetchSites() {
|
||||
try {
|
||||
return await listSites()
|
||||
const list = await listSites()
|
||||
return Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -95,6 +117,184 @@ function rememberMetaSection(open: boolean) {
|
||||
storeMetaSectionOpen(open)
|
||||
}
|
||||
|
||||
function rememberSeoSection(open: boolean) {
|
||||
seoRemembered.value = open
|
||||
storeSeoSectionOpen(open)
|
||||
}
|
||||
|
||||
const seoInEffect = computed(() => panelValues.value ?? draft.seo.value)
|
||||
const siteOfArticle = computed(() =>
|
||||
sites.value.find((site) => site.id === draft.meta.value.siteId),
|
||||
)
|
||||
const addressSlug = computed(
|
||||
() => draft.seo.value.slug || slugify(article.value?.title ?? '', SEO_LIMITS.slug),
|
||||
)
|
||||
const address = computed(
|
||||
() => `${hostOf(siteOfArticle.value?.base_url ?? '')}/${addressSlug.value}`,
|
||||
)
|
||||
|
||||
function checkBody(): SeoCheckBody {
|
||||
const changes = seoChanges(seoInEffect.value, draft.savedSeo.value)
|
||||
if (charCount(changes.excerpt ?? '') > SEO_LIMITS.excerpt) {
|
||||
delete changes.excerpt
|
||||
}
|
||||
return {
|
||||
...changes,
|
||||
...(draft.bodyEdited.value ? { body: draft.body.value } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const check = useSeoCheck({
|
||||
articleId,
|
||||
body: checkBody,
|
||||
onGone: () => {
|
||||
loadState.value = 'notFound'
|
||||
},
|
||||
onUnauthorized: draft.mirrorToStorage,
|
||||
})
|
||||
|
||||
function editBody(newBody: string) {
|
||||
draft.edit(newBody)
|
||||
check.schedule()
|
||||
}
|
||||
|
||||
function openPanel(focus: SeoFocus | null = null) {
|
||||
panelFocus.value = focus
|
||||
panelValues.value = { ...draft.seo.value }
|
||||
panelNotice.value = null
|
||||
slugTakenOnSave.value = null
|
||||
canonicalRejected.value = false
|
||||
panelOpen.value = true
|
||||
check.runFresh()
|
||||
}
|
||||
|
||||
function closePanel(discardEdits = true) {
|
||||
panelOpen.value = false
|
||||
panelValues.value = null
|
||||
if (panelSaveFailed) {
|
||||
panelSaveFailed = false
|
||||
draft.markDirty()
|
||||
}
|
||||
if (discardEdits) {
|
||||
check.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function updatePanelValues(values: ArticleSeo) {
|
||||
panelValues.value = values
|
||||
}
|
||||
|
||||
function onPanelCheck(immediate: boolean) {
|
||||
if (immediate) {
|
||||
check.runNow()
|
||||
return
|
||||
}
|
||||
check.schedule()
|
||||
}
|
||||
|
||||
function focusBody() {
|
||||
detailsOpen.value = false
|
||||
previewOn.value = false
|
||||
nextTick(() => editorRef.value?.focus())
|
||||
}
|
||||
|
||||
function authorsFocusTarget() {
|
||||
const scope = detailsOpen.value
|
||||
? '[role="dialog"] [data-testid="meta-section"]'
|
||||
: '[data-testid="article-details"] [data-testid="meta-section"]'
|
||||
return document.querySelector<HTMLElement>(
|
||||
`${scope} [data-testid="author-row"] button, ${scope} button[aria-haspopup="listbox"]`,
|
||||
)
|
||||
}
|
||||
|
||||
function focusWhenRendered(find: () => HTMLElement | null, retries = FOCUS_RETRIES) {
|
||||
const target = find()
|
||||
if (target) {
|
||||
target.focus()
|
||||
return
|
||||
}
|
||||
if (retries > 0) {
|
||||
setTimeout(() => focusWhenRendered(find, retries - 1), FOCUS_RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function focusAuthors() {
|
||||
remembered.value = true
|
||||
focusWhenRendered(authorsFocusTarget)
|
||||
}
|
||||
|
||||
function goToField(field: SuggestionField) {
|
||||
if (field === 'body') {
|
||||
closePanel()
|
||||
focusBody()
|
||||
return
|
||||
}
|
||||
if (field === 'author_ids') {
|
||||
closePanel()
|
||||
focusAuthors()
|
||||
return
|
||||
}
|
||||
openPanel(field)
|
||||
}
|
||||
|
||||
function showPanelFailure(outcome: SaveOutcome) {
|
||||
if (outcome === 'slug-taken') {
|
||||
slugTakenOnSave.value = panelValues.value?.slug ?? draft.seo.value.slug
|
||||
check.runFresh()
|
||||
return
|
||||
}
|
||||
if (outcome === 'invalid-url') {
|
||||
canonicalRejected.value = true
|
||||
return
|
||||
}
|
||||
panelSaveFailed = true
|
||||
panelNotice.value = {
|
||||
title: t(
|
||||
"Couldn't save search and sharing. Your changes are still here. Try again in a moment.",
|
||||
),
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
function announceSeoSaved() {
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Search and sharing saved')
|
||||
},
|
||||
get description() {
|
||||
return t('Your search and sharing settings are up to date.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function saveSeoPanel() {
|
||||
const values = panelValues.value
|
||||
if (!values || panelSaving.value) {
|
||||
return
|
||||
}
|
||||
draft.editSeo(values)
|
||||
if (draft.state.value !== 'dirty') {
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
panelNotice.value = null
|
||||
panelSaving.value = true
|
||||
const outcome = await runSave()
|
||||
panelSaving.value = false
|
||||
if (outcome === 'saved') {
|
||||
closePanel(false)
|
||||
announceSeoSaved()
|
||||
return
|
||||
}
|
||||
if (['not-found', 'site-gone', 'author-gone', 'unauthorized'].includes(outcome)) {
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
showPanelFailure(outcome)
|
||||
}
|
||||
|
||||
async function loadArticle() {
|
||||
loadState.value = 'loading'
|
||||
showSkeleton.value = false
|
||||
@@ -110,11 +310,17 @@ async function loadArticle() {
|
||||
])
|
||||
article.value = result
|
||||
sites.value = siteList
|
||||
draft.load(result.body, new Date(result.updated_at), {
|
||||
siteId: result.site_id ?? null,
|
||||
authors: toBylines(result.authors ?? []),
|
||||
})
|
||||
draft.load(
|
||||
result.body,
|
||||
new Date(result.updated_at),
|
||||
{
|
||||
siteId: result.site_id ?? null,
|
||||
authors: toBylines(result.authors ?? []),
|
||||
},
|
||||
seoOf(result),
|
||||
)
|
||||
loadState.value = 'loaded'
|
||||
check.runNow()
|
||||
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
|
||||
variantCatalog.load()
|
||||
}
|
||||
@@ -155,8 +361,16 @@ const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.va
|
||||
|
||||
const publishDisabled = computed(() => draft.state.value === 'offline')
|
||||
|
||||
async function runSave() {
|
||||
async function runSave(): Promise<SaveOutcome> {
|
||||
const outcome = await draft.save()
|
||||
if (outcome === 'saved') {
|
||||
check.invalidate()
|
||||
check.runFresh()
|
||||
}
|
||||
if (!panelOpen.value && (outcome === 'slug-taken' || outcome === 'invalid-url')) {
|
||||
openPanel('slug')
|
||||
showPanelFailure(outcome)
|
||||
}
|
||||
if (outcome === 'site-gone') {
|
||||
directoryToasts.siteGone()
|
||||
sites.value = await fetchSites()
|
||||
@@ -173,6 +387,7 @@ async function runSave() {
|
||||
},
|
||||
}))
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
async function performSave() {
|
||||
@@ -199,6 +414,9 @@ async function setOnline(makeOnline: boolean) {
|
||||
}
|
||||
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
|
||||
try {
|
||||
if (makeOnline && draft.state.value === 'dirty' && (await runSave()) !== 'saved') {
|
||||
return
|
||||
}
|
||||
await actions.publish(article.value, makeOnline, {
|
||||
onUpdated: (updated) => {
|
||||
article.value = updated
|
||||
@@ -335,6 +553,19 @@ onBeforeRouteLeave(async () => {
|
||||
@update:authors="draft.editMeta({ authors: $event })"
|
||||
@update:remembered="rememberMetaSection"
|
||||
/>
|
||||
<SearchSharingSection
|
||||
:remembered="seoRemembered"
|
||||
:address="address"
|
||||
:phase="check.phase.value"
|
||||
:status="check.status.value"
|
||||
:suggestions="check.suggestions.value"
|
||||
:sheet="sheet"
|
||||
@update:remembered="rememberSeoSection"
|
||||
@edit="openPanel"
|
||||
@preview="apiPreviewOpen = true"
|
||||
@retry="check.runFresh"
|
||||
@go="goToField"
|
||||
/>
|
||||
</template>
|
||||
</ArticleDetails>
|
||||
</DefineDetailsPanel>
|
||||
@@ -385,6 +616,13 @@ onBeforeRouteLeave(async () => {
|
||||
</div>
|
||||
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-meta" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
||||
<div class="flex min-h-6 items-center gap-2 text-highlighted">
|
||||
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
|
||||
<span class="text-sm font-semibold">{{ t('Search and sharing') }}</span>
|
||||
</div>
|
||||
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-seo" />
|
||||
</div>
|
||||
<USkeleton class="h-3 w-full rounded-none" />
|
||||
<USkeleton class="h-3 w-full rounded-none" />
|
||||
<USkeleton class="h-3 w-3/5 rounded-none" />
|
||||
@@ -489,7 +727,7 @@ onBeforeRouteLeave(async () => {
|
||||
class="h-full"
|
||||
:model-value="draft.body.value"
|
||||
:article-id="articleId"
|
||||
@update:model-value="draft.edit"
|
||||
@update:model-value="editBody"
|
||||
@request-image="onRequestImage"
|
||||
@request-remove-image="onRequestRemoveImage"
|
||||
>
|
||||
@@ -509,6 +747,35 @@ onBeforeRouteLeave(async () => {
|
||||
</ArticleEditor>
|
||||
</div>
|
||||
|
||||
<SeoPanel
|
||||
v-if="panelOpen && panelValues"
|
||||
:model-value="panelValues"
|
||||
:saved-slug="draft.savedSeo.value.slug"
|
||||
:article-title="article.title"
|
||||
:body="draft.body.value"
|
||||
:site="siteOfArticle"
|
||||
:phase="check.phase.value"
|
||||
:suggestions="check.suggestions.value"
|
||||
:slug-verdict="check.slugVerdict.value"
|
||||
:checked-slug="check.checkedSlug.value"
|
||||
:slug-taken-on-save="slugTakenOnSave"
|
||||
:canonical-rejected="canonicalRejected"
|
||||
:saving="panelSaving"
|
||||
:notice="panelNotice"
|
||||
:focus="panelFocus"
|
||||
@update:model-value="updatePanelValues"
|
||||
@check="onPanelCheck"
|
||||
@submit="saveSeoPanel"
|
||||
@close="closePanel"
|
||||
@preview="apiPreviewOpen = true"
|
||||
@go="goToField"
|
||||
/>
|
||||
<ApiPreviewDialog
|
||||
v-model:open="apiPreviewOpen"
|
||||
:article-id="articleId"
|
||||
:is-online="article.is_online"
|
||||
/>
|
||||
|
||||
<ImageUploader
|
||||
v-model:open="imageUploaderOpen"
|
||||
:initial-file="imageUploaderFile"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
@@ -116,6 +116,7 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
const routes = {
|
||||
'GET /v1/site/': () => mockResponse(200, oneSite),
|
||||
'POST /v1/article/12/seo/check': () => mockResponse(200, readyCheck),
|
||||
...handlers,
|
||||
}
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
import { fixture } from './mediaTestKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
@@ -71,6 +71,9 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
|
||||
if (url === '/v1/site/') {
|
||||
return mockResponse(200, oneSite)
|
||||
}
|
||||
if (url === '/v1/article/12/seo/check') {
|
||||
return mockResponse(200, readyCheck)
|
||||
}
|
||||
if (url === '/v1/media/?page=1&size=25') {
|
||||
return mockResponse(200, libraryPage())
|
||||
}
|
||||
@@ -182,7 +185,8 @@ describe('Insert puts the chosen image in the body with its alt text', () => {
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
const methods = vi
|
||||
.mocked(global.fetch)
|
||||
.mock.calls.map(([, init]) => (init as RequestInit | undefined)?.method)
|
||||
.mock.calls.filter(([url]) => !String(url).endsWith('/seo/check'))
|
||||
.map(([, init]) => (init as RequestInit | undefined)?.method)
|
||||
expect(methods.every((method) => method === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -174,7 +174,9 @@ describe('Editor section: the choice is remembered per browser', () => {
|
||||
expect(summary()).toContain('No authors')
|
||||
expect(
|
||||
crowded.calls.filter(
|
||||
(call) => call.method !== 'GET' || call.path.startsWith('/v1/author'),
|
||||
(call) =>
|
||||
(call.method !== 'GET' && !call.path.endsWith('/seo/check')) ||
|
||||
call.path.startsWith('/v1/author'),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { failWith, metaKit, pending, textOf, unmountMeta } from './metaTestKit'
|
||||
import { typeInto } from './directoryTestKit'
|
||||
import {
|
||||
ARTICLE_TITLE,
|
||||
LONG_TITLE,
|
||||
READY,
|
||||
answerChecks,
|
||||
checkAnswer,
|
||||
checkBodies,
|
||||
checkCalls,
|
||||
fieldByLabel,
|
||||
mountSeo,
|
||||
openPanel,
|
||||
openSection,
|
||||
panelRoot,
|
||||
patchBodies,
|
||||
sectionBody,
|
||||
seoBackend,
|
||||
suggestion,
|
||||
} from './seoTestKit'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { SEO_CHECK_DEBOUNCE_MS } from '@/composables/useSeoCheck'
|
||||
|
||||
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(async () => {
|
||||
vi.useRealTimers()
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
function fakeTimers() {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
}
|
||||
|
||||
async function pause(ms = SEO_CHECK_DEBOUNCE_MS) {
|
||||
await vi.advanceTimersByTimeAsync(ms)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function editBody(html: string) {
|
||||
metaKit.wrapper!.findComponent(ArticleEditor).vm.$emit('update:modelValue', html)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function checkRequests() {
|
||||
const mocked = vi.mocked(useApi).mock.results.at(-1)!.value as {
|
||||
request: { mock: { calls: [string, RequestInit | undefined][] } }
|
||||
}
|
||||
return mocked.request.mock.calls.filter(([path]) => path.endsWith('/seo/check'))
|
||||
}
|
||||
|
||||
describe('The first check runs when the editor loads', () => {
|
||||
it('sends exactly one check with an empty body once the article is loaded', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
|
||||
expect(checkBodies(backend)).toEqual([{}])
|
||||
})
|
||||
|
||||
it('sends no check while the article is pending', async () => {
|
||||
const backend = seoBackend()
|
||||
const gate = pending<unknown>()
|
||||
backend.responders['GET /v1/article/12'] = () => gate.promise
|
||||
await mountSeo(backend)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
|
||||
expect(checkCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends no check when the article fails to load', async () => {
|
||||
const backend = seoBackend()
|
||||
backend.responders['GET /v1/article/12'] = failWith(500)
|
||||
await mountSeo(backend)
|
||||
|
||||
expect(checkCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('What the check is sent', () => {
|
||||
it('sends only the changed field', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
fakeTimers()
|
||||
|
||||
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
|
||||
await pause()
|
||||
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ seo_title: LONG_TITLE })
|
||||
})
|
||||
|
||||
it('includes the unsaved body edit so the suggestions reflect what is on screen', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
fakeTimers()
|
||||
|
||||
await editBody('<p>Edited on screen</p>')
|
||||
await pause()
|
||||
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ body: '<p>Edited on screen</p>' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('The check waits for a pause in typing', () => {
|
||||
type Field = { name: string; value: string; key: string; prepare?: () => Promise<void> }
|
||||
|
||||
async function openAdvanced() {
|
||||
const toggle = Array.from(
|
||||
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||
).find((button) => textOf(button).startsWith('Advanced'))!
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function openSocial() {
|
||||
const toggle = Array.from(
|
||||
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||
).find((button) => textOf(button).startsWith('Social sharing'))!
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function unlockSlug() {
|
||||
panelRoot()!.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const fields: Field[] = [
|
||||
{ name: 'Meta title', value: 'A new meta title', key: 'seo_title' },
|
||||
{ name: 'Meta description', value: 'A new meta description', key: 'seo_description' },
|
||||
{ name: 'Excerpt', value: 'A new excerpt', key: 'excerpt' },
|
||||
{ name: 'Alt text', value: 'A tiled wall', key: 'cover_image_alt' },
|
||||
{ name: 'Social title', value: 'A social title', key: 'social_title', prepare: openSocial },
|
||||
{
|
||||
name: 'Canonical URL',
|
||||
value: 'https://renew.example/costs',
|
||||
key: 'canonical_url',
|
||||
prepare: openAdvanced,
|
||||
},
|
||||
{ name: 'URL slug', value: 'bathroom-costs', key: 'slug', prepare: unlockSlug },
|
||||
]
|
||||
|
||||
it.each(fields)('sends one check with the latest $name only after the pause', async (field) => {
|
||||
const backend = await mountSeo(seoBackend({ article: { cover_image_id: 1 } }))
|
||||
await openPanel()
|
||||
await field.prepare?.()
|
||||
fakeTimers()
|
||||
const before = checkCalls(backend).length
|
||||
const input = fieldByLabel(panelRoot()!, field.name)!
|
||||
|
||||
await typeInto(input, field.value.slice(0, 4))
|
||||
await pause(SEO_CHECK_DEBOUNCE_MS - 100)
|
||||
await typeInto(input, field.value)
|
||||
await pause(SEO_CHECK_DEBOUNCE_MS - 100)
|
||||
expect(checkCalls(backend)).toHaveLength(before)
|
||||
|
||||
await pause(100)
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ [field.key]: field.value })
|
||||
})
|
||||
|
||||
it('refreshes the suggestions and the summary line after the pause', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
fakeTimers()
|
||||
answerChecks(
|
||||
backend,
|
||||
checkAnswer([suggestion('title_long', 'seo_title', { length: 74, max: 60 })]),
|
||||
)
|
||||
|
||||
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
|
||||
await pause()
|
||||
|
||||
expect(
|
||||
textOf(panelRoot()!.querySelector('[data-testid="seo-panel-suggestions"]')),
|
||||
).toContain('The meta title is 74 characters.')
|
||||
expect(textOf(sectionBody())).toContain('The meta title is 74 characters.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A late answer never overwrites a newer one', () => {
|
||||
it('shows the suggestions of the newer check and aborts the previous request', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
fakeTimers()
|
||||
const gates: ReturnType<typeof pending<unknown>>[] = []
|
||||
answerChecks(backend, () => {
|
||||
const gate = pending<unknown>()
|
||||
gates.push(gate)
|
||||
return gate.promise
|
||||
})
|
||||
const title = fieldByLabel(panelRoot()!, 'Meta title')!
|
||||
|
||||
await typeInto(title, 'First edit')
|
||||
await pause()
|
||||
await typeInto(title, 'Second edit')
|
||||
await pause()
|
||||
expect(gates).toHaveLength(2)
|
||||
const [first, second] = checkRequests().slice(-2)
|
||||
expect((first![1]!.signal as AbortSignal).aborted).toBe(true)
|
||||
expect((second![1]!.signal as AbortSignal).aborted).toBe(false)
|
||||
|
||||
gates[1]!.resolve(checkAnswer([suggestion('cover_none', 'cover_image_id')], 'suggestions'))
|
||||
await flushPromises()
|
||||
gates[0]!.resolve(checkAnswer([], 'hidden'))
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(sectionBody())).toContain('This article has no cover image.')
|
||||
expect(textOf(sectionBody())).not.toContain('Hidden from search engines')
|
||||
expect(textOf(panelRoot()!)).toContain('This article has no cover image.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Discrete changes check immediately', () => {
|
||||
it('checks without waiting when "Hide from search engines" is toggled', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
fakeTimers()
|
||||
const before = checkCalls(backend).length
|
||||
|
||||
panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ noindex: true })
|
||||
})
|
||||
|
||||
it('checks without waiting when the cover is picked and when it is removed', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
fakeTimers()
|
||||
const before = checkCalls(backend).length
|
||||
const cover = metaKit.wrapper!.findAllComponents(MediaField)[0]!
|
||||
|
||||
cover.vm.$emit('update:modelValue', 2)
|
||||
await flushPromises()
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ cover_image_id: 2 })
|
||||
|
||||
cover.vm.$emit('update:modelValue', null)
|
||||
await flushPromises()
|
||||
expect(checkCalls(backend)).toHaveLength(before + 2)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({})
|
||||
})
|
||||
|
||||
it('checks without waiting when a social image is picked', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
fakeTimers()
|
||||
const before = checkCalls(backend).length
|
||||
Array.from(
|
||||
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||
)
|
||||
.find((button) => textOf(button).startsWith('Social sharing'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
const social = metaKit.wrapper!.findAllComponents(MediaField)[1]!
|
||||
|
||||
social.vm.$emit('update:modelValue', 2)
|
||||
await flushPromises()
|
||||
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ social_image_id: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nothing is saved by a check', () => {
|
||||
it('sends no PATCH for any check and leaves the status unchanged', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
fakeTimers()
|
||||
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
|
||||
await pause()
|
||||
panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(checkCalls(backend).length).toBeGreaterThan(2)
|
||||
expect(patchBodies(backend)).toEqual([])
|
||||
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The server owns the suggestion rules', () => {
|
||||
it('shows no title_long message for a 74-character title when the check does not answer it', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
fakeTimers()
|
||||
answerChecks(backend, READY)
|
||||
|
||||
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
|
||||
await pause()
|
||||
|
||||
expect(LONG_TITLE).toHaveLength(74)
|
||||
expect(textOf(panelRoot()!)).not.toContain('characters. Search engines may cut it off')
|
||||
expect(textOf(panelRoot()!)).toContain('All good')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Discarding the panel', () => {
|
||||
it('aborts a pending check for the discarded values so it cannot overwrite the summary', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
fakeTimers()
|
||||
const gate = pending<unknown>()
|
||||
answerChecks(backend, () => gate.promise)
|
||||
await typeInto(fieldByLabel(panel, 'Meta title')!, LONG_TITLE)
|
||||
await pause()
|
||||
const [request] = checkRequests().slice(-1)
|
||||
|
||||
const cancel = Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => textOf(button) === 'Cancel',
|
||||
)!
|
||||
cancel.click()
|
||||
await flushPromises()
|
||||
gate.resolve(checkAnswer([suggestion('title_long', 'seo_title')]))
|
||||
await flushPromises()
|
||||
|
||||
expect((request![1]!.signal as AbortSignal).aborted).toBe(true)
|
||||
expect(textOf(sectionBody())).not.toContain('The meta title is')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The panel check state', () => {
|
||||
it('sends a check on open and shows Checking this article… while it is pending, with the fields usable', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const before = checkCalls(backend).length
|
||||
const gate = pending<unknown>()
|
||||
answerChecks(backend, () => gate.promise)
|
||||
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
expect(textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))).toContain(
|
||||
'Checking this article…',
|
||||
)
|
||||
const title = fieldByLabel(panel, 'Meta title')!
|
||||
expect(title.disabled).toBe(false)
|
||||
expect(title.placeholder).toBe(ARTICLE_TITLE)
|
||||
void openSection
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { click, failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
|
||||
import {
|
||||
mountSeo,
|
||||
openPanel,
|
||||
openSection,
|
||||
panelRoot,
|
||||
patchBodies,
|
||||
seoBackend,
|
||||
seoCalls,
|
||||
} from './seoTestKit'
|
||||
|
||||
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(async () => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
function resolvedSeo(fallbacks: Record<string, string> = {}) {
|
||||
return {
|
||||
title: 'How we price a bathroom renovation | Renew',
|
||||
description: 'A bathroom renovation is priced by room size.',
|
||||
canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||
robots: 'index, follow',
|
||||
og: {
|
||||
type: 'article',
|
||||
title: 'How we price a bathroom renovation | Renew',
|
||||
description: 'A bathroom renovation is priced by room size.',
|
||||
image: 'https://renew.example/media/north-wall.jpg',
|
||||
image_alt: 'A tiled bathroom wall during renovation',
|
||||
url: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||
site_name: 'Renew',
|
||||
locale: 'en',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'How we price a bathroom renovation | Renew',
|
||||
description: 'A bathroom renovation is priced by room size.',
|
||||
image: 'https://renew.example/media/north-wall.jpg',
|
||||
image_alt: 'A tiled bathroom wall during renovation',
|
||||
},
|
||||
image: {
|
||||
url: 'https://renew.example/media/north-wall.jpg',
|
||||
alt: 'A tiled bathroom wall during renovation',
|
||||
source: 'cover_image',
|
||||
},
|
||||
authors: [
|
||||
{
|
||||
name: 'Inès Duarte',
|
||||
url: 'https://ines-duarte.example',
|
||||
same_as: ['https://www.linkedin.com/in/ines-duarte'],
|
||||
},
|
||||
],
|
||||
published_at: '2026-03-12T09:14:00Z',
|
||||
modified_at: '2026-10-15T11:02:00Z',
|
||||
fallbacks: {
|
||||
title: 'title',
|
||||
description: 'body',
|
||||
image: 'cover',
|
||||
canonical: 'site',
|
||||
...fallbacks,
|
||||
},
|
||||
json_ld: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'How we price a bathroom renovation',
|
||||
datePublished: '2026-03-12T09:14:00Z',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function mountWithDialog(options: { online?: boolean; seo?: unknown; width?: number } = {}) {
|
||||
const backend = seoBackend()
|
||||
backend.article.is_online = options.online ?? true
|
||||
backend.responders['GET /v1/article/12/seo'] = () => options.seo ?? resolvedSeo()
|
||||
await mountSeo(backend, options.width ? { width: options.width } : {})
|
||||
return backend
|
||||
}
|
||||
|
||||
async function openDialog() {
|
||||
await openSection()
|
||||
const opener = document.body.querySelector<HTMLElement>('[data-testid="seo-preview"]')!
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushPromises()
|
||||
return document.body
|
||||
.querySelector<HTMLElement>('[data-testid="api-preview"]')!
|
||||
.closest('[role="dialog"]') as HTMLElement
|
||||
}
|
||||
|
||||
async function selectTab(label: string) {
|
||||
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||
(candidate) => textOf(candidate) === label,
|
||||
)!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function stubClipboard(writeText: ReturnType<typeof vi.fn> | undefined) {
|
||||
vi.stubGlobal('navigator', { ...navigator, clipboard: writeText ? { writeText } : undefined })
|
||||
}
|
||||
|
||||
describe('Opening from the section and from the panel', () => {
|
||||
it('requests GET /v1/article/12/seo once on each opening, from both openers', async () => {
|
||||
const backend = await mountWithDialog()
|
||||
|
||||
await openDialog()
|
||||
expect(seoCalls(backend)).toHaveLength(1)
|
||||
await click('Close')
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
await openDialog()
|
||||
expect(seoCalls(backend)).toHaveLength(2)
|
||||
await click('Close')
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
const panel = await openPanel()
|
||||
await click('What the frontend gets', panel)
|
||||
await flushPromises()
|
||||
expect(seoCalls(backend)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('opens a 720 px dialog on desktop', async () => {
|
||||
await mountWithDialog()
|
||||
const dialog = await openDialog()
|
||||
|
||||
expect(dialog.className).toContain('max-w-180')
|
||||
})
|
||||
|
||||
it('stacks above the Details sheet it can be opened from', async () => {
|
||||
await mountWithDialog({ width: MOBILE_WIDTH })
|
||||
await click('Details')
|
||||
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||
await flushPromises()
|
||||
sheet.querySelector<HTMLElement>('[data-testid="seo-preview"]')!.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const dialog = document
|
||||
.querySelector('[data-testid="api-preview"]')!
|
||||
.closest('[role="dialog"]') as HTMLElement
|
||||
expect(dialog.className).toContain('z-60')
|
||||
expect(document.body.querySelector('[data-slot="overlay"].z-60')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens a bottom sheet on mobile', async () => {
|
||||
await mountWithDialog({ width: MOBILE_WIDTH })
|
||||
await click('Details')
|
||||
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||
await flushPromises()
|
||||
sheet.querySelector<HTMLElement>('[data-testid="seo-preview"]')!.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushPromises()
|
||||
|
||||
const dialog = document
|
||||
.querySelector('[data-testid="api-preview"]')!
|
||||
.closest('[role="dialog"]') as HTMLElement
|
||||
expect(dialog.className).toContain('max-sm:!bottom-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Formatted', () => {
|
||||
it('shows the title, the description, the tabs and the buttons', async () => {
|
||||
await mountWithDialog()
|
||||
const dialog = await openDialog()
|
||||
|
||||
const text = textOf(dialog)
|
||||
expect(text).toContain('What the frontend gets')
|
||||
expect(text).toContain(
|
||||
"The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does.",
|
||||
)
|
||||
expect(text).toContain('Formatted')
|
||||
expect(text).toContain('Raw JSON')
|
||||
expect(text).toContain('Copy JSON')
|
||||
expect(text).toContain('Close')
|
||||
})
|
||||
|
||||
it('lists the keys as a compact key and value list', async () => {
|
||||
await mountWithDialog()
|
||||
const dialog = await openDialog()
|
||||
|
||||
const keys = Array.from(dialog.querySelectorAll('dt')).map((node) => textOf(node))
|
||||
expect(keys).toEqual([
|
||||
'title',
|
||||
'description',
|
||||
'canonical_url',
|
||||
'robots',
|
||||
'og.type',
|
||||
'og.image',
|
||||
'og.image_alt',
|
||||
'twitter.card',
|
||||
'authors',
|
||||
'published_at',
|
||||
'modified_at',
|
||||
'json_ld',
|
||||
])
|
||||
const formatted = textOf(dialog.querySelector('[data-testid="api-preview-formatted"]'))
|
||||
expect(formatted).toContain('How we price a bathroom renovation | Renew')
|
||||
expect(formatted).toContain('Inès Duarte · ines-duarte.example · 1 social link')
|
||||
expect(formatted).toContain('12 Mar 2026, 09:14')
|
||||
expect(formatted).toContain('Article · headline, datePublished')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ key: 'title', source: 'title', chip: 'Using article title' },
|
||||
{ key: 'description', source: 'body', chip: 'Using first sentence of the body' },
|
||||
{ key: 'description', source: 'excerpt', chip: 'Using the excerpt' },
|
||||
{ key: 'description', source: 'site', chip: 'Using the site default description' },
|
||||
{ key: 'image', source: 'cover', chip: 'Using cover image' },
|
||||
{ key: 'image', source: 'site', chip: 'Using site social image' },
|
||||
{ key: 'canonical', source: 'site', chip: 'Using site address and slug' },
|
||||
{ key: 'image', source: 'none', chip: 'No image' },
|
||||
])('shows "$chip" when fallbacks.$key is $source', async ({ key, source, chip }) => {
|
||||
await mountWithDialog({
|
||||
seo: resolvedSeo({
|
||||
title: 'seo_title',
|
||||
description: 'seo_description',
|
||||
image: 'social',
|
||||
canonical: 'canonical_url',
|
||||
[key]: source,
|
||||
}),
|
||||
})
|
||||
const dialog = await openDialog()
|
||||
|
||||
const chips = Array.from(dialog.querySelectorAll('[data-testid="fallback-chip"]')).map(
|
||||
(node) => textOf(node),
|
||||
)
|
||||
expect(chips).toEqual([chip])
|
||||
})
|
||||
|
||||
it('shows no chip for a value that is not a fallback', async () => {
|
||||
await mountWithDialog({
|
||||
seo: resolvedSeo({
|
||||
title: 'seo_title',
|
||||
description: 'seo_description',
|
||||
image: 'social',
|
||||
canonical: 'canonical_url',
|
||||
}),
|
||||
})
|
||||
const dialog = await openDialog()
|
||||
|
||||
expect(dialog.querySelectorAll('[data-testid="fallback-chip"]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Raw JSON', () => {
|
||||
it('shows the response exactly as the API returned it, pretty printed in the mono face', async () => {
|
||||
const seo = resolvedSeo()
|
||||
await mountWithDialog({ seo })
|
||||
const dialog = await openDialog()
|
||||
|
||||
await selectTab('Raw JSON')
|
||||
|
||||
const raw = dialog.querySelector<HTMLElement>('[data-testid="api-preview-raw"]')!
|
||||
expect(raw.textContent).toBe(JSON.stringify(seo, null, 2))
|
||||
expect(raw.className).toContain('font-mono')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy JSON', () => {
|
||||
it('writes the pretty printed raw object, reads Copied for 2 seconds, announces it, then reads Copy JSON again', async () => {
|
||||
const seo = resolvedSeo()
|
||||
await mountWithDialog({ seo })
|
||||
const dialog = await openDialog()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
stubClipboard(writeText)
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
await click('Copy JSON', dialog)
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(JSON.stringify(seo, null, 2))
|
||||
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copied')
|
||||
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('Copied')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1900)
|
||||
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copied')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flushPromises()
|
||||
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copy JSON')
|
||||
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['rejects', vi.fn().mockRejectedValue(new Error('denied'))],
|
||||
['is unavailable', undefined],
|
||||
])('keeps reading Copy JSON when the clipboard %s', async (_name, writeText) => {
|
||||
await mountWithDialog()
|
||||
const dialog = await openDialog()
|
||||
stubClipboard(writeText)
|
||||
|
||||
await click('Copy JSON', dialog)
|
||||
|
||||
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copy JSON')
|
||||
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Draft article', () => {
|
||||
it('shows the draft note above the data, and the data', async () => {
|
||||
await mountWithDialog({ online: false })
|
||||
const dialog = await openDialog()
|
||||
|
||||
const note = dialog.querySelector('[data-testid="api-preview-draft"]')!
|
||||
expect(textOf(note)).toContain(
|
||||
'This article is a draft. The API returns this data only to signed-in callers until it is published.',
|
||||
)
|
||||
expect(textOf(dialog.querySelector('[data-testid="api-preview-formatted"]'))).toContain(
|
||||
'robots',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows no draft note for an online article', async () => {
|
||||
await mountWithDialog({ online: true })
|
||||
const dialog = await openDialog()
|
||||
|
||||
expect(dialog.querySelector('[data-testid="api-preview-draft"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading', () => {
|
||||
it('shows a loading state and disables Copy JSON while the request is pending', async () => {
|
||||
const backend = await mountWithDialog()
|
||||
const gate = pending<unknown>()
|
||||
backend.responders['GET /v1/article/12/seo'] = () => gate.promise
|
||||
const dialog = await openDialog()
|
||||
|
||||
expect(dialog.querySelector('[data-testid="api-preview-loading"]')).not.toBeNull()
|
||||
expect(
|
||||
dialog.querySelector<HTMLButtonElement>('[data-testid="api-preview-copy"]')!.disabled,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error with retry', () => {
|
||||
it.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])(
|
||||
'shows the error and requests the data again on Try again after %s',
|
||||
async (_name, failure) => {
|
||||
const backend = await mountWithDialog()
|
||||
backend.responders['GET /v1/article/12/seo'] = failure
|
||||
const dialog = await openDialog()
|
||||
|
||||
const text = textOf(dialog)
|
||||
expect(text).toContain("Couldn't load the SEO data")
|
||||
expect(text).toContain(
|
||||
'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.',
|
||||
)
|
||||
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||
expect(patchBodies(backend)).toEqual([])
|
||||
const before = seoCalls(backend).length
|
||||
|
||||
backend.responders['GET /v1/article/12/seo'] = () => resolvedSeo()
|
||||
await click('Try again', dialog)
|
||||
|
||||
expect(seoCalls(backend)).toHaveLength(before + 1)
|
||||
expect(dialog.querySelector('[data-testid="api-preview-formatted"]')).not.toBeNull()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('The dialog is read only', () => {
|
||||
it('sends no PATCH and no POST, and Close returns focus to its opener', async () => {
|
||||
const backend = await mountWithDialog()
|
||||
const callsBefore = backend.calls.length
|
||||
const dialog = await openDialog()
|
||||
|
||||
await click('Close', dialog)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(backend.calls.slice(callsBefore).filter((call) => call.method !== 'GET')).toEqual([])
|
||||
expect(document.body.querySelector('[data-testid="api-preview"]')).toBeNull()
|
||||
expect(document.activeElement).toBe(
|
||||
document.body.querySelector('[data-testid="seo-preview"]'),
|
||||
)
|
||||
void panelRoot
|
||||
})
|
||||
|
||||
it('closes with Escape and returns focus to its opener', async () => {
|
||||
await mountWithDialog()
|
||||
const dialog = await openDialog()
|
||||
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="api-preview"]')).toBeNull()
|
||||
expect(document.activeElement).toBe(
|
||||
document.body.querySelector('[data-testid="seo-preview"]'),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { click, textOf, unmountMeta } from './metaTestKit'
|
||||
import {
|
||||
FOUR_SUGGESTIONS,
|
||||
answerChecks,
|
||||
checkAnswer,
|
||||
mountSeo,
|
||||
openPanel,
|
||||
openSection,
|
||||
panelRoot,
|
||||
seoBackend,
|
||||
} from './seoTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
setLocale('fr')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountMeta()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('French', () => {
|
||||
it('walks through the section, the panel and the dialog in French', async () => {
|
||||
const backend = seoBackend()
|
||||
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||
backend.responders['GET /v1/article/12/seo'] = () => ({
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
canonical_url: 'https://renew.example/x',
|
||||
robots: 'index, follow',
|
||||
og: { type: 'article', image: '', image_alt: '' },
|
||||
twitter: { card: 'summary' },
|
||||
image: { url: '', alt: '', source: 'none' },
|
||||
authors: [],
|
||||
published_at: null,
|
||||
modified_at: '2026-09-26T11:02:00Z',
|
||||
fallbacks: { title: 'title', description: 'body', image: 'none', canonical: 'site' },
|
||||
json_ld: { '@type': 'Article' },
|
||||
})
|
||||
await mountSeo(backend)
|
||||
expect(document.body.querySelector('[data-testid="seo-summary"]')!.textContent).toBe(
|
||||
'4 suggestions',
|
||||
)
|
||||
|
||||
const body = await openSection()
|
||||
const section = textOf(body)
|
||||
expect(section).toContain('Adresse')
|
||||
expect(section).toContain(
|
||||
'Le titre meta fait 74 caractères. Les moteurs de recherche peuvent le couper après environ 60.',
|
||||
)
|
||||
expect(section).toContain('Aller au titre meta')
|
||||
expect(section).toContain('+1 de plus dans Recherche et partage')
|
||||
expect(section).toContain('Modifier la recherche et le partage')
|
||||
|
||||
const panel = await openPanel()
|
||||
const panelContent = textOf(panel)
|
||||
expect(panelContent).toContain('Recherche et partage')
|
||||
expect(panelContent).toContain('Slug de l’URL')
|
||||
expect(panelContent).toContain('Titre de l’article utilisé')
|
||||
expect(panelContent).toContain('Partage social')
|
||||
expect(panelContent).toContain('Avancé')
|
||||
expect(panelContent).toContain('Masquer des moteurs de recherche')
|
||||
expect(panelContent).toContain('Résultat de recherche')
|
||||
expect(panelContent).toContain('Carte sociale')
|
||||
expect(panelContent).toContain('Enregistrer les modifications')
|
||||
await click('Annuler', panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
expect(panelRoot()).toBeNull()
|
||||
|
||||
await click('Ce que reçoit le frontend')
|
||||
await flushPromises()
|
||||
const dialog = textOf(document.body.querySelector('[data-testid="api-preview"]'))
|
||||
expect(dialog).toContain('Formaté')
|
||||
expect(dialog).toContain('JSON brut')
|
||||
expect(dialog).toContain('Copier le JSON')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { config, flushPromises } from '@vue/test-utils'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||
import { SEO_SECTION_OPEN_KEY } from '@/lib/seoSectionMemory'
|
||||
import { click, unmountMeta } from './metaTestKit'
|
||||
import {
|
||||
FOUR_SUGGESTIONS,
|
||||
answerChecks,
|
||||
checkAnswer,
|
||||
mountSeo,
|
||||
openPanel,
|
||||
openSection,
|
||||
section,
|
||||
sectionBody,
|
||||
sectionToggle,
|
||||
seoBackend,
|
||||
} from './seoTestKit'
|
||||
|
||||
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(async () => {
|
||||
config.global.config.errorHandler = undefined
|
||||
vi.restoreAllMocks()
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
function securityError() {
|
||||
return new DOMException('Access denied', 'SecurityError')
|
||||
}
|
||||
|
||||
function collectVueErrors() {
|
||||
const uncaught: unknown[] = []
|
||||
const previous = config.global.config.errorHandler
|
||||
config.global.config.errorHandler = (error) => {
|
||||
uncaught.push(error)
|
||||
}
|
||||
return {
|
||||
uncaught,
|
||||
stop: () => {
|
||||
config.global.config.errorHandler = previous
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('The choice is remembered per browser, with its own key', () => {
|
||||
it('uses a key of its own, independent of the Site and authors key', () => {
|
||||
expect(SEO_SECTION_OPEN_KEY).not.toBe(META_SECTION_OPEN_KEY)
|
||||
})
|
||||
|
||||
it('keeps an opening for the next article and a collapse after a reload, sending nothing to the API', async () => {
|
||||
const backend = seoBackend()
|
||||
await mountSeo(backend)
|
||||
const callsBefore = backend.calls.length
|
||||
await openSection()
|
||||
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
|
||||
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBeNull()
|
||||
expect(backend.calls).toHaveLength(callsBefore)
|
||||
await unmountMeta()
|
||||
|
||||
await mountSeo(seoBackend())
|
||||
expect(sectionBody()).not.toBeNull()
|
||||
sectionToggle().click()
|
||||
await flushPromises()
|
||||
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('false')
|
||||
await unmountMeta()
|
||||
|
||||
await mountSeo(seoBackend())
|
||||
expect(sectionBody()).toBeNull()
|
||||
})
|
||||
|
||||
it('shares the choice between the sidebar and the Details sheet', async () => {
|
||||
await mountSeo(seoBackend(), { width: MOBILE_WIDTH })
|
||||
await click('Details')
|
||||
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
|
||||
const sidebar = document.body.querySelector('[data-testid="article-details"]')!
|
||||
expect(sidebar.querySelector('[data-testid="seo-body"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not change the stored choice when the panel or the dialog is opened from the section', async () => {
|
||||
const backend = seoBackend()
|
||||
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||
await mountSeo(backend)
|
||||
await openSection()
|
||||
const writes = vi.spyOn(Storage.prototype, 'setItem')
|
||||
|
||||
const panel = await openPanel()
|
||||
expect(panel).not.toBeNull()
|
||||
expect(writes).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
|
||||
expect(section()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A storage that throws never breaks the editor', () => {
|
||||
it.each(['getItem', 'setItem'] as const)(
|
||||
'renders the editor and toggles the section for this page view when localStorage.%s throws',
|
||||
async (call) => {
|
||||
const errors = collectVueErrors()
|
||||
vi.spyOn(Storage.prototype, call).mockImplementation(() => {
|
||||
throw securityError()
|
||||
})
|
||||
|
||||
await mountSeo(seoBackend())
|
||||
|
||||
expect(section()).not.toBeNull()
|
||||
expect(sectionBody()).toBeNull()
|
||||
sectionToggle().click()
|
||||
await flushPromises()
|
||||
expect(sectionBody()).not.toBeNull()
|
||||
sectionToggle().click()
|
||||
await flushPromises()
|
||||
expect(sectionBody()).toBeNull()
|
||||
errors.stop()
|
||||
expect(errors.uncaught).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('falls back to collapsed when reading throws, even if a choice was stored', async () => {
|
||||
localStorage.setItem(SEO_SECTION_OPEN_KEY, 'true')
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw securityError()
|
||||
})
|
||||
|
||||
await mountSeo(seoBackend())
|
||||
|
||||
expect(sectionBody()).toBeNull()
|
||||
})
|
||||
|
||||
it('silently skips the write when setItem throws', async () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw securityError()
|
||||
})
|
||||
|
||||
await mountSeo(seoBackend())
|
||||
await openSection()
|
||||
|
||||
expect(setItem).toHaveBeenCalled()
|
||||
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,571 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { click, textOf, unmountMeta } from './metaTestKit'
|
||||
import { blur, typeInto } from './directoryTestKit'
|
||||
import {
|
||||
ARTICLE_SLUG,
|
||||
ARTICLE_TITLE,
|
||||
FIRST_SENTENCE,
|
||||
LONG_TITLE,
|
||||
answerChecks,
|
||||
checkAnswer,
|
||||
checkBodies,
|
||||
counterTexts,
|
||||
fieldByLabel,
|
||||
mountSeo,
|
||||
openDisclosure,
|
||||
openPanel,
|
||||
openSection,
|
||||
panelRoot,
|
||||
patchBodies,
|
||||
renewSite,
|
||||
saveChangesButton,
|
||||
seoBackend,
|
||||
suggestion,
|
||||
} from './seoTestKit'
|
||||
|
||||
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(async () => {
|
||||
vi.useRealTimers()
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
function panelText() {
|
||||
return textOf(panelRoot()!)
|
||||
}
|
||||
|
||||
function chips(root: ParentNode = panelRoot()!) {
|
||||
return Array.from(root.querySelectorAll('[data-testid="fallback-chip"]')).map((chip) =>
|
||||
textOf(chip),
|
||||
)
|
||||
}
|
||||
|
||||
function switchElement() {
|
||||
return panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!
|
||||
}
|
||||
|
||||
function filledArticle() {
|
||||
return {
|
||||
seo_title: 'What a bathroom renovation costs',
|
||||
seo_description:
|
||||
'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.',
|
||||
excerpt: 'How we price a bathroom renovation, from the survey to the final invoice.',
|
||||
cover_image_id: 1,
|
||||
cover_image_alt: 'A tiled bathroom wall during renovation',
|
||||
}
|
||||
}
|
||||
|
||||
describe('Opening', () => {
|
||||
it('opens a 960 px slide-over on desktop with the fields, the previews, Cancel and Save changes', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(panel.className).toContain('max-w-240')
|
||||
expect(textOf(panel.querySelector('h2, [data-slot="title"]'))).toBe('Search and sharing')
|
||||
expect(panelText()).toContain('Search result')
|
||||
expect(panelText()).toContain('Social card')
|
||||
expect(panelText()).toContain('Cancel')
|
||||
expect(textOf(saveChangesButton(panel))).toBe('Save changes')
|
||||
})
|
||||
|
||||
it('opens a sheet on mobile with the suggestions first, then the fields, then the two previews', async () => {
|
||||
await mountSeo(seoBackend(), { width: MOBILE_WIDTH })
|
||||
await click('Details')
|
||||
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||
await flushPromises()
|
||||
sheet.querySelector<HTMLElement>('[data-testid="seo-edit"]')!.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushPromises()
|
||||
|
||||
const panel = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
|
||||
).find((dialog) => dialog.querySelector('[data-testid="seo-panel-suggestions"]'))!
|
||||
expect(panel.className).not.toContain('max-w-240')
|
||||
expect(panel.className).toContain('h-23/25')
|
||||
const text = textOf(panel)
|
||||
const positions = ['All good', 'URL slug', 'Search result', 'Social card'].map((part) =>
|
||||
text.indexOf(part),
|
||||
)
|
||||
expect(Math.min(...positions)).toBeGreaterThanOrEqual(0)
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Defaults, every value is a fallback and says so', () => {
|
||||
it('shows the fallback of every field and its chip for an article with no SEO field set', async () => {
|
||||
const backend = seoBackend({ sites: [renewSite({ default_social_image_id: 1 })] })
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
|
||||
const text = panelText()
|
||||
expect(text).toContain('URL slug')
|
||||
expect(text).toContain('Auto')
|
||||
expect(text).toContain('Generated from the title until you edit it.')
|
||||
expect(text).toContain('Meta title')
|
||||
expect(text).toContain('Recommended 30 to 60 characters. This is a guide, not a limit.')
|
||||
expect(text).toContain('Meta description')
|
||||
expect(text).toContain('Recommended 70 to 160 characters.')
|
||||
expect(text).toContain('Excerpt')
|
||||
expect(text).toContain('Using the site social image (north-wall.jpg).')
|
||||
expect(text).toContain('Social sharing')
|
||||
expect(text).toContain('Optional')
|
||||
expect(text).toContain('Uses the meta values')
|
||||
expect(text).toContain('Advanced')
|
||||
expect(text).toContain('Canonical URL: automatic')
|
||||
expect(text).toContain('Hide from search engines')
|
||||
expect(text).toContain(
|
||||
"Sets noindex in the API. It doesn't change who can read the article.",
|
||||
)
|
||||
expect(text).toContain('Suggestions')
|
||||
expect(text).toContain('What the frontend gets')
|
||||
expect(chips()).toContain('Using article title')
|
||||
expect(chips()).toContain('Using first sentence of the body')
|
||||
expect(switchElement().getAttribute('aria-checked')).toBe('false')
|
||||
expect(fieldByLabel(panelRoot()!, 'Meta title')!.placeholder).toBe(ARTICLE_TITLE)
|
||||
expect(fieldByLabel(panelRoot()!, 'Meta description')!.placeholder).toBe(FIRST_SENTENCE)
|
||||
expect(fieldByLabel(panelRoot()!, 'Excerpt')!.placeholder).toBe(
|
||||
'A short summary. It is also sent as the article teaser.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the excerpt chip when there is an excerpt but no meta description', async () => {
|
||||
await mountSeo(seoBackend({ article: { excerpt: 'An excerpt long enough to be used.' } }))
|
||||
await openPanel()
|
||||
|
||||
expect(chips()).toContain('Using the excerpt')
|
||||
expect(chips()).not.toContain('Using first sentence of the body')
|
||||
})
|
||||
|
||||
it('shows the site default description chip when there is no description, excerpt or body text', async () => {
|
||||
const backend = seoBackend({
|
||||
article: { body: '' },
|
||||
sites: [renewSite({ default_description: 'One or two sentences about the site.' })],
|
||||
})
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
|
||||
expect(chips()).toContain('Using the site default description')
|
||||
})
|
||||
|
||||
it('shows the No image chip on the social card when there is no cover and no site social image', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
|
||||
expect(chips(panelRoot()!.querySelector('[data-testid="social-card"]')!)).toContain(
|
||||
'No image',
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the fallback chips and shows All good when the article has its own values', async () => {
|
||||
await mountSeo(seoBackend({ article: filledArticle() }))
|
||||
const panel = await openPanel()
|
||||
|
||||
const fieldChips = chips(panel).filter(
|
||||
(chip) => !['Using meta title', 'Using meta description'].includes(chip),
|
||||
)
|
||||
expect(fieldChips).not.toContain('Using article title')
|
||||
expect(fieldChips).not.toContain('Using first sentence of the body')
|
||||
expect(fieldChips).not.toContain('Using the excerpt')
|
||||
expect(textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))).toContain(
|
||||
'All good Nothing to suggest for this article.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Soft counters are a guide', () => {
|
||||
it.each([
|
||||
{ field: 'Meta title', n: 29, max: 60, amber: true },
|
||||
{ field: 'Meta title', n: 30, max: 60, amber: false },
|
||||
{ field: 'Meta title', n: 60, max: 60, amber: false },
|
||||
{ field: 'Meta title', n: 74, max: 60, amber: true },
|
||||
{ field: 'Meta description', n: 41, max: 160, amber: true },
|
||||
{ field: 'Meta description', n: 70, max: 160, amber: false },
|
||||
{ field: 'Meta description', n: 160, max: 160, amber: false },
|
||||
{ field: 'Meta description', n: 161, max: 160, amber: true },
|
||||
])('reads $n / $max for the $field and amber is $amber', async ({ field, n, max, amber }) => {
|
||||
await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
|
||||
await typeInto(fieldByLabel(panel, field)!, 'x'.repeat(n))
|
||||
|
||||
const counter = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>('[data-testid="soft-counter"]'),
|
||||
).find((node) => textOf(node) === `${n} / ${max}`)!
|
||||
expect(counter).toBeInstanceOf(HTMLElement)
|
||||
expect(counter.className).toContain('font-mono')
|
||||
expect(counter.className).toContain(amber ? 'text-warning' : 'text-muted')
|
||||
expect(counter.className).not.toContain('text-error')
|
||||
expect(saveChangesButton(panel).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('turns both counters amber, never red, outside the soft ranges, and cuts the preview off', async () => {
|
||||
const backend = seoBackend()
|
||||
answerChecks(
|
||||
backend,
|
||||
checkAnswer([
|
||||
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||
suggestion('desc_short', 'seo_description', { length: 33, min: 70 }),
|
||||
]),
|
||||
)
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
|
||||
await typeInto(fieldByLabel(panel, 'Meta title')!, LONG_TITLE)
|
||||
await typeInto(
|
||||
fieldByLabel(panel, 'Meta description')!,
|
||||
'What a bathroom renovation costs.',
|
||||
)
|
||||
|
||||
const counters = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>('[data-testid="soft-counter"]'),
|
||||
).slice(0, 2)
|
||||
expect(counters.map((node) => node.className)).toEqual([
|
||||
expect.stringContaining('text-warning'),
|
||||
expect.stringContaining('text-warning'),
|
||||
])
|
||||
expect(counters.some((node) => node.className.includes('text-error'))).toBe(false)
|
||||
const suggestions = textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))
|
||||
expect(suggestions).toContain('The meta title is 74 characters.')
|
||||
expect(suggestions).toContain('The description is 33 characters.')
|
||||
expect(panelText()).toContain('Cut off here, the way a search engine would.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The API length limits are not reachable', () => {
|
||||
it('stops each input at the limit the API refuses beyond', async () => {
|
||||
await mountSeo(seoBackend({ article: { cover_image_id: 1 } }))
|
||||
const panel = await openPanel()
|
||||
await openDisclosure('Social sharing')
|
||||
await openDisclosure('Advanced')
|
||||
|
||||
const limits: Record<string, string> = {
|
||||
'Meta title': '120',
|
||||
'Meta description': '320',
|
||||
'Alt text': '200',
|
||||
'Social title': '120',
|
||||
'Social description': '320',
|
||||
'Canonical URL': '2048',
|
||||
'URL slug': '100',
|
||||
}
|
||||
for (const [label, max] of Object.entries(limits)) {
|
||||
expect(fieldByLabel(panel, label)!.getAttribute('maxlength'), label).toBe(max)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Excerpt over 300', () => {
|
||||
it('shows an inline error and disables Save changes until it is 300 or fewer, sending nothing for it', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const excerpt = fieldByLabel(panel, 'Excerpt')!
|
||||
|
||||
await typeInto(excerpt, 'x'.repeat(301))
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
await flushPromises()
|
||||
|
||||
expect(panelText()).toContain('Shorten the excerpt to 300 characters or fewer.')
|
||||
expect(panelText()).toContain('301 / 300')
|
||||
expect(saveChangesButton(panel).disabled).toBe(true)
|
||||
expect(checkBodies(backend).every((body) => !('excerpt' in (body ?? {})))).toBe(true)
|
||||
expect(patchBodies(backend)).toEqual([])
|
||||
|
||||
await typeInto(excerpt, 'x'.repeat(300))
|
||||
expect(saveChangesButton(panel).disabled).toBe(false)
|
||||
expect(panelText()).not.toContain('Shorten the excerpt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slug edited, Auto and Reset', () => {
|
||||
it('drops the Auto badge and offers Reset when the pencil is used, and Reset brings Auto back', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
expect(panelText()).toContain('Auto')
|
||||
|
||||
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
|
||||
await flushPromises()
|
||||
const slug = fieldByLabel(panel, 'URL slug')!
|
||||
expect(panelText()).not.toContain('Generated from the title until you edit it.')
|
||||
expect(slug.readOnly).toBe(false)
|
||||
expect(slug.value).toBe(ARTICLE_SLUG)
|
||||
expect(panelText()).toContain('Reset to match the title')
|
||||
|
||||
await typeInto(slug, 'bathroom-costs')
|
||||
expect(slug.value).toBe('bathroom-costs')
|
||||
|
||||
await click('Reset to match the title')
|
||||
expect(fieldByLabel(panel, 'URL slug')!.value).toBe(ARTICLE_SLUG)
|
||||
expect(panelText()).toContain('Auto')
|
||||
expect(panelText()).not.toContain('Reset to match the title')
|
||||
void backend
|
||||
})
|
||||
|
||||
it('sends the slug as an empty string when a custom slug is reset, and checks it', async () => {
|
||||
const backend = await mountSeo(seoBackend({ article: { slug: 'bathroom-costs' } }))
|
||||
const panel = await openPanel()
|
||||
expect(fieldByLabel(panel, 'URL slug')!.value).toBe('bathroom-costs')
|
||||
expect(panelText()).toContain('Reset to match the title')
|
||||
|
||||
await click('Reset to match the title')
|
||||
|
||||
expect(fieldByLabel(panel, 'URL slug')!.value).toBe(ARTICLE_SLUG)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ slug: '' })
|
||||
saveChangesButton(panel).click()
|
||||
await flushPromises()
|
||||
expect(patchBodies(backend)).toEqual([{ slug: '' }])
|
||||
})
|
||||
|
||||
it('normalises what is typed to lowercase letters, numbers and hyphens', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
await typeInto(fieldByLabel(panel, 'URL slug')!, 'Bathroom Costs!')
|
||||
|
||||
expect(fieldByLabel(panel, 'URL slug')!.value).toBe('bathroom-costs-')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cover and alt text', () => {
|
||||
async function pickNorthWall(panel: HTMLElement) {
|
||||
const choose = Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => textOf(button) === 'Choose image',
|
||||
)!
|
||||
choose.click()
|
||||
await flushPromises()
|
||||
const dialogs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
|
||||
const picker = dialogs.at(-1)!
|
||||
return picker
|
||||
}
|
||||
|
||||
it('opens the shared picker titled Choose a cover image with Use image, and shows the alt field once a cover is picked', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
const picker = await pickNorthWall(panel)
|
||||
|
||||
expect(textOf(picker)).toContain('Choose a cover image')
|
||||
expect(textOf(picker)).toContain('The cover for this article.')
|
||||
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(picker.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((button) => textOf(button) === 'Use image')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(panelText()).toContain('north-wall.jpg')
|
||||
const alt = fieldByLabel(panel, 'Alt text')!
|
||||
expect(alt.placeholder).toBe("Describe the image for people who can't see it")
|
||||
expect(panelText()).toContain(
|
||||
'Needed before the cover counts as ready. Sent as image alt in the API.',
|
||||
)
|
||||
})
|
||||
|
||||
it('brings the site social image line back and sends a null cover on save when the cover is removed', async () => {
|
||||
const backend = await mountSeo(
|
||||
seoBackend({
|
||||
article: { cover_image_id: 1, cover_image_alt: 'A wall' },
|
||||
sites: [renewSite({ default_social_image_id: 2 })],
|
||||
}),
|
||||
)
|
||||
const panel = await openPanel()
|
||||
expect(panelText()).not.toContain('Using the site social image')
|
||||
|
||||
await click('Remove', panel)
|
||||
|
||||
expect(panelText()).toContain('Using the site social image (kitchen-after.webp).')
|
||||
saveChangesButton(panel).click()
|
||||
await flushPromises()
|
||||
expect(patchBodies(backend)).toEqual([{ cover_image_id: null }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Social sharing', () => {
|
||||
it('shows the Using chips while nothing is set, and Customised with the overrides on the card once it is', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await openDisclosure('Social sharing')
|
||||
|
||||
expect(chips()).toEqual(
|
||||
expect.arrayContaining(['Using meta title', 'Using meta description']),
|
||||
)
|
||||
expect(panelText()).toContain('Using the cover image.')
|
||||
expect(panelText()).not.toContain('Customised')
|
||||
|
||||
await typeInto(fieldByLabel(panel, 'Social title')!, 'Bathroom costs, explained')
|
||||
await typeInto(
|
||||
fieldByLabel(panel, 'Social description')!,
|
||||
'A plain guide to what drives the price.',
|
||||
)
|
||||
|
||||
const card = panel.querySelector<HTMLElement>('[data-testid="social-card"]')!
|
||||
expect(textOf(card.querySelector('[data-testid="social-card-title"]'))).toBe(
|
||||
'Bathroom costs, explained',
|
||||
)
|
||||
expect(textOf(card.querySelector('[data-testid="social-card-description"]'))).toBe(
|
||||
'A plain guide to what drives the price.',
|
||||
)
|
||||
expect(chips()).not.toContain('Using meta title')
|
||||
expect(chips()).not.toContain('Using meta description')
|
||||
expect(panelText()).toContain('Customised')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Advanced, canonical URL', () => {
|
||||
it.each([
|
||||
{ value: 'renew.example/costs', error: true },
|
||||
{ value: 'http://renew.example/a', error: true },
|
||||
{ value: 'https://renew.example/a', error: false },
|
||||
{ value: '', error: false },
|
||||
])('for "$value" the error is shown: $error', async ({ value, error }) => {
|
||||
await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await openDisclosure('Advanced')
|
||||
const canonical = fieldByLabel(panel, 'Canonical URL')!
|
||||
|
||||
await typeInto(canonical, value)
|
||||
await blur(canonical)
|
||||
|
||||
const message = 'Enter a full address starting with https://'
|
||||
expect(panelText().includes(message)).toBe(error)
|
||||
expect(canonical.getAttribute('aria-invalid') === 'true').toBe(error)
|
||||
})
|
||||
|
||||
it('shows Canonical URL: automatic when collapsed and empty', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
await openPanel()
|
||||
|
||||
expect(panelText()).toContain('Canonical URL: automatic')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hide from search engines', () => {
|
||||
it('keeps the helper visible and turns the switch on, and the next check answers hidden', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
answerChecks(backend, checkAnswer([], 'hidden'))
|
||||
|
||||
switchElement().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(switchElement().getAttribute('aria-checked')).toBe('true')
|
||||
expect(textOf(panel)).toContain(
|
||||
"Sets noindex in the API. It doesn't change who can read the article.",
|
||||
)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ noindex: true })
|
||||
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
|
||||
'Hidden from search engines. This only sets a flag in the API.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The search result preview', () => {
|
||||
it.each([
|
||||
{ device: 'Desktop', title: 60, description: 160 },
|
||||
{ device: 'Mobile', title: 70, description: 120 },
|
||||
])(
|
||||
'cuts the title at $title and the description at $description on $device',
|
||||
async (limits) => {
|
||||
await mountSeo(
|
||||
seoBackend({
|
||||
article: { seo_title: LONG_TITLE, seo_description: 'd'.repeat(200) },
|
||||
}),
|
||||
)
|
||||
const panel = await openPanel()
|
||||
|
||||
const tab = Array.from(panel.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||
(candidate) => textOf(candidate) === limits.device,
|
||||
)!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
|
||||
const title = textOf(panel.querySelector('[data-testid="search-preview-title"]'))
|
||||
const description = textOf(
|
||||
panel.querySelector('[data-testid="search-preview-description"]'),
|
||||
)
|
||||
expect([...title]).toHaveLength(limits.title)
|
||||
expect(title.endsWith('…')).toBe(true)
|
||||
expect([...description]).toHaveLength(limits.description)
|
||||
expect(description.endsWith('…')).toBe(true)
|
||||
const card = panel.querySelector<HTMLElement>('[data-testid="search-preview-card"]')!
|
||||
expect(card.className.includes('md:w-70')).toBe(limits.device === 'Mobile')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('The previews follow the typing, with the fallbacks', () => {
|
||||
it('shows the article title with the site title template when the meta title is cleared', async () => {
|
||||
await mountSeo(seoBackend({ article: { seo_title: 'Typed meta title' } }))
|
||||
const panel = await openPanel()
|
||||
expect(textOf(panel.querySelector('[data-testid="search-preview-title"]'))).toBe(
|
||||
'Typed meta title | Renew',
|
||||
)
|
||||
|
||||
await typeInto(fieldByLabel(panel, 'Meta title')!, '')
|
||||
|
||||
expect(textOf(panel.querySelector('[data-testid="search-preview-title"]'))).toBe(
|
||||
`${ARTICLE_TITLE} | Renew`,
|
||||
)
|
||||
const card = panel.querySelector<HTMLElement>('[data-testid="social-card"]')!
|
||||
expect(card.querySelector('[data-testid="social-card-image"]')!.className).toContain(
|
||||
'aspect-191/100',
|
||||
)
|
||||
expect(textOf(card)).toContain('renew.example')
|
||||
expect(textOf(card.querySelector('[data-testid="social-card-title"]'))).toBe(
|
||||
`${ARTICLE_TITLE} | Renew`,
|
||||
)
|
||||
expect(card.querySelector('[data-testid="social-card-description"]')!.className).toContain(
|
||||
'line-clamp-2',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard', () => {
|
||||
it('closes the panel with Escape as Cancel does, discards the typing and returns focus to Edit search and sharing', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await typeInto(fieldByLabel(panel, 'Meta title')!, 'Typed then discarded')
|
||||
|
||||
panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(panelRoot()).toBeNull()
|
||||
expect(document.activeElement).toBe(document.body.querySelector('[data-testid="seo-edit"]'))
|
||||
expect(patchBodies(backend)).toEqual([])
|
||||
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||
|
||||
await openPanel()
|
||||
expect(fieldByLabel(panelRoot()!, 'Meta title')!.value).toBe('')
|
||||
})
|
||||
|
||||
it('discards the typing with Cancel without changing the status', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await typeInto(fieldByLabel(panel, 'Meta title')!, 'Typed then discarded')
|
||||
|
||||
await click('Cancel', panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(panelRoot()).toBeNull()
|
||||
expect(patchBodies(backend)).toEqual([])
|
||||
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||
void openSection
|
||||
void counterTexts
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,446 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
click,
|
||||
failWith,
|
||||
metaKit,
|
||||
networkFailure,
|
||||
pending,
|
||||
textOf,
|
||||
unmountMeta,
|
||||
} from './metaTestKit'
|
||||
import { typeInto } from './directoryTestKit'
|
||||
import {
|
||||
FOUR_SUGGESTIONS,
|
||||
answerChecks,
|
||||
checkAnswer,
|
||||
checkBodies,
|
||||
checkCalls,
|
||||
fieldByLabel,
|
||||
mountSeo,
|
||||
openDisclosure,
|
||||
openPanel,
|
||||
panelRoot,
|
||||
patchBodies,
|
||||
saveChangesButton,
|
||||
seoBackend,
|
||||
} from './seoTestKit'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import router from '@/router'
|
||||
|
||||
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(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
const NOTICE =
|
||||
"Couldn't save search and sharing. Your changes are still here. Try again in a moment."
|
||||
|
||||
function toasts() {
|
||||
return textOf(document.body.querySelector('[data-slot="viewport"]'))
|
||||
}
|
||||
|
||||
function saveButton() {
|
||||
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')!
|
||||
}
|
||||
|
||||
async function changeTitle(panel: HTMLElement, value = 'A new meta title') {
|
||||
await typeInto(fieldByLabel(panel, 'Meta title')!, value)
|
||||
}
|
||||
|
||||
async function submit(panel: HTMLElement) {
|
||||
saveChangesButton(panel).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function editBody(html: string) {
|
||||
metaKit.wrapper!.findComponent(ArticleEditor).vm.$emit('update:modelValue', html)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function failedPanelSave(failure = failWith(500)) {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await changeTitle(panel)
|
||||
backend.responders['PATCH /v1/article/12'] = failure
|
||||
await submit(panel)
|
||||
return { backend, panel }
|
||||
}
|
||||
|
||||
describe('Save changes commits the panel to the draft and saves', () => {
|
||||
it('sends exactly one PATCH holding seo_title only', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
|
||||
await changeTitle(panel)
|
||||
await submit(panel)
|
||||
|
||||
expect(patchBodies(backend)).toEqual([{ seo_title: 'A new meta title' }])
|
||||
})
|
||||
|
||||
it('sends the fields that changed and no unchanged one', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await openDisclosure('Social sharing')
|
||||
await openDisclosure('Advanced')
|
||||
|
||||
await typeInto(fieldByLabel(panel, 'Excerpt')!, 'A short summary')
|
||||
await typeInto(fieldByLabel(panel, 'Social title')!, 'Shared title')
|
||||
await typeInto(fieldByLabel(panel, 'Canonical URL')!, 'https://renew.example/costs')
|
||||
panel.querySelector<HTMLElement>('button[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
await submit(panel)
|
||||
|
||||
expect(patchBodies(backend)).toEqual([
|
||||
{
|
||||
excerpt: 'A short summary',
|
||||
social_title: 'Shared title',
|
||||
canonical_url: 'https://renew.example/costs',
|
||||
noindex: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the pending body edit in the same PATCH', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
await editBody('<p>Edited on screen</p>')
|
||||
const panel = await openPanel()
|
||||
|
||||
await changeTitle(panel)
|
||||
await submit(panel)
|
||||
|
||||
expect(patchBodies(backend)).toEqual([
|
||||
{ seo_title: 'A new meta title', body: '<p>Edited on screen</p>' },
|
||||
])
|
||||
})
|
||||
|
||||
it('closes without a request when nothing changed', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
|
||||
await submit(panel)
|
||||
|
||||
expect(patchBodies(backend)).toEqual([])
|
||||
expect(panelRoot()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving state', () => {
|
||||
it('disables every field of the panel and reads Saving… while the request runs', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await changeTitle(panel)
|
||||
const gate = pending<unknown>()
|
||||
backend.responders['PATCH /v1/article/12'] = () => gate.promise
|
||||
|
||||
await submit(panel)
|
||||
|
||||
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(true)
|
||||
expect(fieldByLabel(panel, 'Meta description')!.disabled).toBe(true)
|
||||
expect(fieldByLabel(panel, 'Excerpt')!.disabled).toBe(true)
|
||||
expect(textOf(panel)).toContain('Saving…')
|
||||
expect(
|
||||
textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement),
|
||||
).toContain('Save')
|
||||
expect(document.body.textContent).toContain('Saving…')
|
||||
gate.resolve({})
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved', () => {
|
||||
it('closes the panel, reads Saved, refreshes the check and toasts', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await changeTitle(panel)
|
||||
const before = checkCalls(backend).length
|
||||
|
||||
await submit(panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(panelRoot()).toBeNull()
|
||||
expect(document.body.textContent).toContain('Saved')
|
||||
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({})
|
||||
expect(toasts()).toContain('Search and sharing saved')
|
||||
expect(toasts()).toContain('Your search and sharing settings are up to date.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved refreshes the check', () => {
|
||||
it('shows the answer of the new check, not the cached one, after the panel closes', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await changeTitle(panel)
|
||||
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||
|
||||
await submit(panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(panelRoot()).toBeNull()
|
||||
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
|
||||
'The meta title is 74 characters.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save failed', () => {
|
||||
it.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('keeps the panel open with the notice and the values on %s', async (_name, failure) => {
|
||||
const { panel } = await failedPanelSave(failure)
|
||||
|
||||
expect(panelRoot()).not.toBeNull()
|
||||
expect(textOf(panel)).toContain(NOTICE)
|
||||
expect(fieldByLabel(panel, 'Meta title')!.value).toBe('A new meta title')
|
||||
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(false)
|
||||
expect(saveChangesButton(panel).disabled).toBe(false)
|
||||
expect(document.body.textContent).toContain('Save failed')
|
||||
expect(document.body.textContent).toContain('Retry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The editor Save button treats SEO edits like any other edit', () => {
|
||||
async function closeAfterFailure() {
|
||||
const { backend, panel } = await failedPanelSave()
|
||||
await click('Cancel', panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
backend.responders['PATCH /v1/article/12'] = () => backend.article
|
||||
backend.responders['PATCH /v1/article/12/publish'] = () => ({
|
||||
...backend.article,
|
||||
is_online: true,
|
||||
})
|
||||
return backend
|
||||
}
|
||||
|
||||
it('reads Unsaved changes, enables Save and offers Save and publish', async () => {
|
||||
await closeAfterFailure()
|
||||
|
||||
expect(document.body.textContent).toContain('Unsaved changes')
|
||||
expect(saveButton().disabled).toBe(false)
|
||||
expect(document.body.textContent).toContain('Save and publish')
|
||||
})
|
||||
|
||||
it('sends seo_title with the Save button', async () => {
|
||||
const backend = await closeAfterFailure()
|
||||
|
||||
saveButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
|
||||
})
|
||||
|
||||
it('sends seo_title with Ctrl+S', async () => {
|
||||
const backend = await closeAfterFailure()
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
|
||||
})
|
||||
|
||||
it('sends seo_title with Cmd+S', async () => {
|
||||
const backend = await closeAfterFailure()
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
|
||||
})
|
||||
|
||||
it('saves with Save and publish and then sends the publish request', async () => {
|
||||
const backend = await closeAfterFailure()
|
||||
const before = backend.calls.length
|
||||
|
||||
await click('Save and publish')
|
||||
|
||||
const after = backend.calls.slice(before).filter((call) => call.method === 'PATCH')
|
||||
expect(after.map((call) => call.path)).toEqual(['/v1/article/12', '/v1/article/12/publish'])
|
||||
expect(after[0]!.body).toEqual({ seo_title: 'A new meta title' })
|
||||
})
|
||||
|
||||
it('does not publish when the save in front of it fails', async () => {
|
||||
const backend = await closeAfterFailure()
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(500)
|
||||
|
||||
await click('Save and publish')
|
||||
|
||||
expect(backend.calls.filter((call) => call.path === '/v1/article/12/publish')).toHaveLength(
|
||||
0,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving with unsaved SEO edits', () => {
|
||||
it('asks Leave without saving? when the draft holds an unsaved SEO value', async () => {
|
||||
const { panel } = await failedPanelSave()
|
||||
await click('Cancel', panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const leaving = router.push('/article')
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
|
||||
'Leave without saving?',
|
||||
)
|
||||
await click('Keep editing')
|
||||
await leaving
|
||||
})
|
||||
})
|
||||
|
||||
describe('A storage that throws never loses the editor', () => {
|
||||
it('saves, and asks before leaving, with the values in memory when sessionStorage throws', async () => {
|
||||
;['getItem', 'setItem', 'removeItem'].forEach((method) =>
|
||||
vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(function (
|
||||
this: Storage,
|
||||
) {
|
||||
if (this === sessionStorage) {
|
||||
throw new DOMException('blocked', 'SecurityError')
|
||||
}
|
||||
return null
|
||||
}),
|
||||
)
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await changeTitle(panel)
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(500)
|
||||
await submit(panel)
|
||||
await click('Cancel', panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
const leaving = router.push('/article')
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
|
||||
'Leave without saving?',
|
||||
)
|
||||
await click('Keep editing')
|
||||
await leaving
|
||||
|
||||
backend.responders['PATCH /v1/article/12'] = () => backend.article
|
||||
saveButton().click()
|
||||
await flushPromises()
|
||||
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Suggestions never block', () => {
|
||||
it.each([
|
||||
['suggestions', checkAnswer(FOUR_SUGGESTIONS)],
|
||||
['hidden', checkAnswer(FOUR_SUGGESTIONS, 'hidden')],
|
||||
])(
|
||||
'keeps Save, Save and publish and Ctrl+S working when the check says %s',
|
||||
async (_name, answer) => {
|
||||
const backend = seoBackend()
|
||||
answerChecks(backend, answer)
|
||||
await mountSeo(backend)
|
||||
await editBody('<p>Edited</p>')
|
||||
|
||||
expect(saveButton().disabled).toBe(false)
|
||||
expect(document.body.textContent).toContain('Save and publish')
|
||||
const publish = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('button'),
|
||||
).find((button) => textOf(button) === 'Save and publish')!
|
||||
expect(publish.disabled).toBe(false)
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
|
||||
await flushPromises()
|
||||
expect(patchBodies(backend)).toEqual([{ body: '<p>Edited</p>' }])
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Cancel', () => {
|
||||
it('discards the typing without a request and without changing the status', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
await changeTitle(panel)
|
||||
const calls = backend.calls.length
|
||||
|
||||
await click('Cancel', panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(panelRoot()).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||
expect(backend.calls.slice(calls).filter((call) => call.method !== 'GET')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Offline', () => {
|
||||
it('shows the failed check, fails the dialog and lets the writing continue', async () => {
|
||||
const backend = seoBackend()
|
||||
backend.responders['POST /v1/article/12/seo/check'] = networkFailure
|
||||
backend.responders['GET /v1/article/12/seo'] = networkFailure
|
||||
await mountSeo(backend)
|
||||
document.body.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
|
||||
"Couldn't check this article.",
|
||||
)
|
||||
await click('What the frontend gets')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain("Couldn't load the SEO data")
|
||||
|
||||
await editBody('<p>Still writing</p>')
|
||||
expect(document.body.textContent).toContain('Unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles', () => {
|
||||
it.each(['owner', 'admin', 'verified'] as const)(
|
||||
'makes the section, the panel and the dialog available to a %s',
|
||||
async (role) => {
|
||||
const backend = seoBackend({ role })
|
||||
backend.responders['GET /v1/article/12/seo'] = () => ({
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
canonical_url: 'https://renew.example/x',
|
||||
robots: 'index, follow',
|
||||
og: { type: 'article', image: '', image_alt: '' },
|
||||
twitter: { card: 'summary' },
|
||||
image: { url: '', alt: '', source: 'none' },
|
||||
authors: [],
|
||||
published_at: null,
|
||||
modified_at: '2026-09-26T11:02:00Z',
|
||||
fallbacks: {
|
||||
title: 'title',
|
||||
description: 'body',
|
||||
image: 'none',
|
||||
canonical: 'site',
|
||||
},
|
||||
json_ld: { '@type': 'Article' },
|
||||
})
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
expect(textOf(panel)).toContain('Search and sharing')
|
||||
await click('Cancel', panel)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
await click('What the frontend gets')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="api-preview"]')).not.toBeNull()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { click, failWith, metaSection, pending, textOf, unmountMeta } from './metaTestKit'
|
||||
import {
|
||||
answerChecks,
|
||||
checkAnswer,
|
||||
checkCalls,
|
||||
mountSeo,
|
||||
section,
|
||||
sectionSummary,
|
||||
seoBackend,
|
||||
suggestion,
|
||||
} from './seoTestKit'
|
||||
|
||||
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)
|
||||
|
||||
describe('Position, in the sidebar and in the Details sheet', () => {
|
||||
it('shows a collapsed section directly under Site and authors in the details sidebar', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
|
||||
const details = document.body.querySelector('[data-testid="article-details"]')!
|
||||
const seo = section()!
|
||||
expect(details.contains(seo)).toBe(true)
|
||||
expect(textOf(seo)).toContain('Search and sharing')
|
||||
expect(seo.previousElementSibling).toBe(metaSection())
|
||||
expect(seo.querySelector('[data-testid="seo-toggle"]')!.getAttribute('aria-expanded')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(seo.querySelector('[data-testid="seo-body"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the section out of sight on mobile until the Details sheet is opened, under Site and authors', async () => {
|
||||
await mountSeo(seoBackend(), { width: MOBILE_WIDTH })
|
||||
|
||||
expect(document.body.querySelector('[data-testid="article-details"]')!.className).toContain(
|
||||
'hidden',
|
||||
)
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
|
||||
await click('Details')
|
||||
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
const inSheet = section(sheet)!
|
||||
expect(inSheet.previousElementSibling).toBe(
|
||||
sheet.querySelector('[data-testid="meta-section"]'),
|
||||
)
|
||||
expect(sectionSummary(inSheet)?.textContent).toBe('Ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The one-line summary follows the check', () => {
|
||||
it.each([
|
||||
{ name: 'pending', check: null, summary: 'Checking…', warning: false },
|
||||
{ name: 'ready', check: checkAnswer([]), summary: 'Ready', warning: false },
|
||||
{
|
||||
name: 'one suggestion',
|
||||
check: checkAnswer([suggestion('cover_none', 'cover_image_id')]),
|
||||
summary: '1 suggestion',
|
||||
warning: true,
|
||||
},
|
||||
{
|
||||
name: 'two suggestions',
|
||||
check: checkAnswer([
|
||||
suggestion('cover_none', 'cover_image_id'),
|
||||
suggestion('no_au', 'author_ids'),
|
||||
]),
|
||||
summary: '2 suggestions',
|
||||
warning: true,
|
||||
},
|
||||
{
|
||||
name: 'hidden with two suggestions',
|
||||
check: checkAnswer(
|
||||
[suggestion('cover_none', 'cover_image_id'), suggestion('no_au', 'author_ids')],
|
||||
'hidden',
|
||||
),
|
||||
summary: 'Hidden from search',
|
||||
warning: false,
|
||||
},
|
||||
])('reads "$summary" for a $name check', async ({ check, summary, warning }) => {
|
||||
const backend = seoBackend()
|
||||
const gate = pending<unknown>()
|
||||
answerChecks(backend, () => (check ? check : gate.promise))
|
||||
await mountSeo(backend)
|
||||
|
||||
const line = sectionSummary()!
|
||||
expect(line.textContent).toBe(summary)
|
||||
expect(line.className).toContain(warning ? 'text-warning' : 'text-muted')
|
||||
expect(line.className).not.toContain(warning ? 'text-muted' : 'text-warning')
|
||||
})
|
||||
|
||||
it("reads Couldn't check this article. rather than Ready when the check fails", async () => {
|
||||
const backend = seoBackend()
|
||||
backend.responders['POST /v1/article/12/seo/check'] = failWith(500)
|
||||
await mountSeo(backend)
|
||||
|
||||
expect(sectionSummary()!.textContent).toBe("Couldn't check this article.")
|
||||
})
|
||||
})
|
||||
|
||||
describe('The existing Editor states show the section', () => {
|
||||
it('shows the collapsed section with Ready in the loaded editor', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
|
||||
expect(sectionSummary()!.textContent).toBe('Ready')
|
||||
})
|
||||
|
||||
it('shows the section header with a skeleton summary while the article loads', async () => {
|
||||
const backend = seoBackend()
|
||||
const gate = pending<unknown>()
|
||||
backend.responders['GET /v1/article/12'] = () => gate.promise
|
||||
await mountSeo(backend)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await flushPromises()
|
||||
|
||||
const skeleton = document.body.querySelector('[data-testid="skeleton-details"]')!
|
||||
expect(textOf(skeleton)).toContain('Search and sharing')
|
||||
expect(skeleton.querySelector('[data-testid="skeleton-seo"]')).not.toBeNull()
|
||||
expect(section()).toBeNull()
|
||||
expect(checkCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows no section in the Not found state', async () => {
|
||||
const backend = seoBackend()
|
||||
backend.responders['GET /v1/article/12'] = failWith(404)
|
||||
await mountSeo(backend)
|
||||
|
||||
expect(section()).toBeNull()
|
||||
expect(checkCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { typeInto } from './directoryTestKit'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
|
||||
|
||||
function reply(status: number, body: unknown = null, text = '') {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
const article = {
|
||||
id: 12,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '<p>Intro</p>',
|
||||
is_online: false,
|
||||
site_id: 1,
|
||||
authors: [{ id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }],
|
||||
slug: 'how-we-price-a-bathroom-renovation',
|
||||
excerpt: '',
|
||||
seo_title: '',
|
||||
seo_description: '',
|
||||
cover_image_id: null,
|
||||
cover_image_alt: '',
|
||||
social_title: '',
|
||||
social_description: '',
|
||||
social_image_id: null,
|
||||
canonical_url: '',
|
||||
noindex: false,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-09-26T11:02:00Z',
|
||||
}
|
||||
|
||||
const ready = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
|
||||
|
||||
type Handler = () => Response | Promise<Response>
|
||||
|
||||
let wrapper: ReturnType<typeof mount> | null = null
|
||||
|
||||
function stubFetch(handlers: Record<string, Handler>) {
|
||||
const routes: Record<string, Handler> = {
|
||||
'GET /v1/site/': () => reply(200, oneSite),
|
||||
'GET /v1/article/12?format=html': () => reply(200, article),
|
||||
'POST /v1/article/12/seo/check': () => reply(200, ready),
|
||||
'GET /v1/media/?page=1&size=100&sort=newest': () => reply(200, { items: [] }),
|
||||
...handlers,
|
||||
}
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const handler = routes[`${init?.method ?? 'GET'} ${url}`]
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected fetch call: ${init?.method ?? 'GET'} ${url}`)
|
||||
}
|
||||
return Promise.resolve(handler())
|
||||
}) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
async function mountEditor() {
|
||||
setActivePinia(createPinia())
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push('/article/12/edit')
|
||||
wrapper = mount(RootComponent, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('A session problem during the check follows the existing rules', () => {
|
||||
it('lands on the login page with the redirect back to the editor when the check answers 401', async () => {
|
||||
stubFetch({ 'POST /v1/article/12/seo/check': () => reply(401) })
|
||||
|
||||
await mountEditor()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query).toMatchObject({
|
||||
redirect: '/article/12/edit',
|
||||
reason: 'expired',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the Not found editor state when the check answers 404', async () => {
|
||||
stubFetch({ 'POST /v1/article/12/seo/check': () => reply(404) })
|
||||
|
||||
await mountEditor()
|
||||
|
||||
expect(document.body.textContent).toContain("This article doesn't exist.")
|
||||
expect(document.body.querySelector('[data-testid="seo-section"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session expiry keeps the SEO draft', () => {
|
||||
it('mirrors the draft to sessionStorage on 401 and restores it, with the panel fields, after logging in', async () => {
|
||||
stubFetch({ 'PATCH /v1/article/12?format=html': () => reply(401) })
|
||||
await mountEditor()
|
||||
const panel = await openPanel()
|
||||
await typeInto(fieldByLabel(panel, 'Meta title')!, 'A title kept across the login')
|
||||
saveChangesButton(panel).click()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
||||
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)
|
||||
expect(mirrored.seo_title).toBe('A title kept across the login')
|
||||
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
stubFetch({})
|
||||
await mountEditor()
|
||||
|
||||
expect(document.body.textContent).toContain('Unsaved changes')
|
||||
const restored = await openPanel()
|
||||
expect(fieldByLabel(restored, 'Meta title')!.value).toBe('A title kept across the login')
|
||||
expect(panelRoot()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { click, failWith, textOf, unmountMeta } from './metaTestKit'
|
||||
import { blur, typeInto } from './directoryTestKit'
|
||||
import {
|
||||
checkAnswer,
|
||||
checkBodies,
|
||||
checkCalls,
|
||||
answerChecks,
|
||||
fieldByLabel,
|
||||
mountSeo,
|
||||
openPanel,
|
||||
panelRoot,
|
||||
patchBodies,
|
||||
saveChangesButton,
|
||||
seoBackend,
|
||||
} from './seoTestKit'
|
||||
|
||||
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 TAKEN = checkAnswer([], 'ready', { available: false, suggestion: 'bathroom-costs-2' })
|
||||
|
||||
async function typeSlug(panel: HTMLElement, value: string) {
|
||||
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
|
||||
await flushPromises()
|
||||
const slug = fieldByLabel(panel, 'URL slug')!
|
||||
await typeInto(slug, value)
|
||||
return slug
|
||||
}
|
||||
|
||||
function slugButtons(panel: HTMLElement) {
|
||||
return Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).map((button) =>
|
||||
textOf(button),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Slug availability is checked on blur', () => {
|
||||
it('sends one check with the typed slug and shows the taken state with the suggestion', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
answerChecks(backend, TAKEN)
|
||||
|
||||
const slug = await typeSlug(panel, 'bathroom-costs')
|
||||
await blur(slug)
|
||||
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ slug: 'bathroom-costs' })
|
||||
expect(textOf(panel)).toContain('The slug "bathroom-costs" is already used on Renew.')
|
||||
expect(slugButtons(panel)).toContain('Use bathroom-costs-2')
|
||||
})
|
||||
|
||||
it('puts the suggested slug in manual mode, clears the error and the action and checks it', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
answerChecks(backend, TAKEN)
|
||||
const slug = await typeSlug(panel, 'bathroom-costs')
|
||||
await blur(slug)
|
||||
const before = checkCalls(backend).length
|
||||
answerChecks(backend, checkAnswer([]))
|
||||
|
||||
await click('Use bathroom-costs-2', panel)
|
||||
|
||||
const field = fieldByLabel(panel, 'URL slug')!
|
||||
expect(field.value).toBe('bathroom-costs-2')
|
||||
expect(field.readOnly).toBe(false)
|
||||
expect(textOf(panel)).not.toContain('is already used on Renew.')
|
||||
expect(slugButtons(panel)).not.toContain('Use bathroom-costs-2')
|
||||
expect(textOf(panel)).toContain('Reset to match the title')
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
expect(checkBodies(backend).at(-1)).toEqual({ slug: 'bathroom-costs-2' })
|
||||
})
|
||||
|
||||
it('shows neither an error nor an action for a slug the article already owns', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
answerChecks(backend, checkAnswer([], 'ready', { available: true, suggestion: null }))
|
||||
|
||||
const slug = await typeSlug(panel, 'bathroom-costs')
|
||||
await blur(slug)
|
||||
|
||||
expect(textOf(panel)).not.toContain('is already used')
|
||||
expect(slugButtons(panel).some((label) => label.startsWith('Use '))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slug taken on save', () => {
|
||||
it('keeps the panel open with the taken state and the suggestion of a follow-up check, applying nothing', async () => {
|
||||
const backend = await mountSeo(seoBackend())
|
||||
const panel = await openPanel()
|
||||
const slug = await typeSlug(panel, 'bathroom-costs')
|
||||
await typeInto(fieldByLabel(panel, 'Meta title')!, 'A kept title')
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(409, 'slug_taken')
|
||||
answerChecks(backend, TAKEN)
|
||||
void slug
|
||||
|
||||
saveChangesButton(panel).click()
|
||||
await flushPromises()
|
||||
|
||||
expect(panelRoot()).not.toBeNull()
|
||||
expect(textOf(panel)).toContain('The slug "bathroom-costs" is already used on Renew.')
|
||||
expect(slugButtons(panel)).toContain('Use bathroom-costs-2')
|
||||
expect(fieldByLabel(panel, 'Meta title')!.value).toBe('A kept title')
|
||||
expect(checkBodies(backend).at(-1)).toEqual({
|
||||
slug: 'bathroom-costs',
|
||||
seo_title: 'A kept title',
|
||||
})
|
||||
expect(patchBodies(backend)).toEqual([
|
||||
{ seo_title: 'A kept title', slug: 'bathroom-costs' },
|
||||
])
|
||||
expect(document.body.textContent).toContain('Unsaved changes')
|
||||
expect(document.body.textContent).not.toContain('Save failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
click,
|
||||
failWith,
|
||||
networkFailure,
|
||||
pending,
|
||||
renewSites,
|
||||
textOf,
|
||||
unmountMeta,
|
||||
} from './metaTestKit'
|
||||
import {
|
||||
ARTICLE_SLUG,
|
||||
ARTICLE_TITLE,
|
||||
FOUR_SUGGESTIONS,
|
||||
answerChecks,
|
||||
checkAnswer,
|
||||
checkCalls,
|
||||
mountSeo,
|
||||
openSection,
|
||||
panelRoot,
|
||||
patchBodies,
|
||||
renewSite,
|
||||
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)
|
||||
|
||||
function suggestionRows() {
|
||||
return Array.from(sectionBody()!.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
|
||||
}
|
||||
|
||||
function toasts() {
|
||||
return textOf(document.body.querySelector('[data-slot="viewport"]'))
|
||||
}
|
||||
|
||||
describe('The open section: address, suggestions and actions', () => {
|
||||
it('shows the address, All good and the two actions when the check is ready', async () => {
|
||||
await mountSeo(seoBackend())
|
||||
const body = await openSection()
|
||||
|
||||
const text = textOf(body)
|
||||
expect(text).toContain('Address')
|
||||
expect(text).toContain(`renew.example/${ARTICLE_SLUG}`)
|
||||
expect(text).not.toContain('https://')
|
||||
expect(text).toContain('All good')
|
||||
expect(text).toContain('Nothing to suggest for this article.')
|
||||
expect(text).toContain('Edit search and sharing')
|
||||
expect(text).toContain('What the frontend gets')
|
||||
})
|
||||
|
||||
it('shows the host of the article site and the slug for another site', async () => {
|
||||
const journal = renewSite({
|
||||
...renewSites()[1]!,
|
||||
site_name: 'Journal',
|
||||
title_template: '%title% | %site%',
|
||||
base_url: 'https://journal.renew.example',
|
||||
})
|
||||
const backend = seoBackend({ sites: [renewSite(), journal], article: { site_id: 2 } })
|
||||
await mountSeo(backend)
|
||||
const body = await openSection()
|
||||
|
||||
expect(textOf(body)).toContain(`journal.renew.example/${ARTICLE_SLUG}`)
|
||||
})
|
||||
|
||||
it('shows at most three suggestions, in the order of the response, each with its link', async () => {
|
||||
const backend = seoBackend()
|
||||
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||
await mountSeo(backend)
|
||||
await openSection()
|
||||
|
||||
const rows = suggestionRows()
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows.map((row) => textOf(row))).toEqual([
|
||||
'The meta title is 74 characters. Search engines may cut it off after about 60. Go to meta title',
|
||||
'The cover image has no alt text. Add a short description of it. Go to cover image',
|
||||
'The body has no headings. H2 headings help readers scan the article. Go to body',
|
||||
])
|
||||
expect(textOf(sectionBody())).toContain('+1 more in Search and sharing')
|
||||
})
|
||||
|
||||
it('opens the panel with the suggestion list in view from "+1 more in Search and sharing"', async () => {
|
||||
const backend = seoBackend()
|
||||
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||
await mountSeo(backend)
|
||||
await openSection()
|
||||
|
||||
await click('+1 more in Search and sharing')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushPromises()
|
||||
|
||||
const panel = panelRoot()!
|
||||
expect(panel).not.toBeNull()
|
||||
const suggestions = panel.querySelector('[data-testid="seo-panel-suggestions"]')!
|
||||
expect(document.activeElement).toBe(suggestions)
|
||||
expect(textOf(suggestions)).toContain('The body is 40 characters.')
|
||||
})
|
||||
|
||||
it('shows no "+N more" action with three suggestions or fewer', async () => {
|
||||
const backend = seoBackend()
|
||||
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS.slice(0, 3)))
|
||||
await mountSeo(backend)
|
||||
await openSection()
|
||||
|
||||
expect(suggestionRows()).toHaveLength(3)
|
||||
expect(textOf(sectionBody())).not.toContain('more in Search and sharing')
|
||||
})
|
||||
|
||||
it('shows Checking this article… and keeps the address and both actions usable while the check is pending', async () => {
|
||||
const backend = seoBackend()
|
||||
const gate = pending<unknown>()
|
||||
answerChecks(backend, () => gate.promise)
|
||||
await mountSeo(backend)
|
||||
const body = await openSection()
|
||||
|
||||
const text = textOf(body)
|
||||
expect(text).toContain('Checking this article…')
|
||||
expect(text).toContain(`renew.example/${ARTICLE_SLUG}`)
|
||||
const edit = body.querySelector<HTMLButtonElement>('[data-testid="seo-edit"]')!
|
||||
const preview = body.querySelector<HTMLButtonElement>('[data-testid="seo-preview"]')!
|
||||
expect(edit.disabled).toBe(false)
|
||||
expect(preview.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('explains the flag when the article is hidden from search', async () => {
|
||||
const backend = seoBackend({ article: { noindex: true } })
|
||||
answerChecks(backend, checkAnswer([], 'hidden'))
|
||||
await mountSeo(backend)
|
||||
const body = await openSection()
|
||||
|
||||
expect(textOf(body)).toContain(
|
||||
'Hidden from search engines. This only sets a flag in the API.',
|
||||
)
|
||||
expect(sectionSummary()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Where a suggestion link goes', () => {
|
||||
async function followLink(field: Parameters<typeof suggestion>[1], label: string) {
|
||||
const backend = seoBackend({ article: { cover_image_id: 1 } })
|
||||
answerChecks(
|
||||
backend,
|
||||
checkAnswer([suggestion('title_long', field, { length: 74, max: 60 })]),
|
||||
)
|
||||
await mountSeo(backend)
|
||||
await openSection()
|
||||
await click(label)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await flushPromises()
|
||||
return backend
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
field: 'seo_title',
|
||||
label: 'Go to meta title',
|
||||
focused: (panel: HTMLElement) =>
|
||||
panel.querySelector(`[placeholder="${ARTICLE_TITLE}"]`),
|
||||
},
|
||||
{
|
||||
field: 'seo_description',
|
||||
label: 'Go to meta description',
|
||||
focused: (panel: HTMLElement) => panel.querySelector('textarea[maxlength="320"]'),
|
||||
},
|
||||
{
|
||||
field: 'cover_image_alt',
|
||||
label: 'Go to cover image',
|
||||
focused: (panel: HTMLElement) =>
|
||||
panel.querySelector(
|
||||
'[placeholder="Describe the image for people who can\'t see it"]',
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'slug',
|
||||
label: 'Go to URL slug',
|
||||
focused: (panel: HTMLElement) => panel.querySelector('input[maxlength="100"]'),
|
||||
},
|
||||
] as const)(
|
||||
'opens the panel with focus on the $field field',
|
||||
async ({ field, label, focused }) => {
|
||||
await followLink(field, label)
|
||||
|
||||
const panel = panelRoot()!
|
||||
expect(panel).not.toBeNull()
|
||||
expect(document.activeElement).toBe(focused(panel))
|
||||
expect(document.activeElement).not.toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('opens the panel with focus on the cover Choose image button', async () => {
|
||||
await followLink('cover_image_id', 'Go to cover image')
|
||||
|
||||
const active = document.activeElement as HTMLElement
|
||||
expect(panelRoot()!.contains(active)).toBe(true)
|
||||
expect(textOf(active)).toMatch(/Choose image|Change image/)
|
||||
})
|
||||
|
||||
it('moves focus to the article body for a body suggestion', async () => {
|
||||
const focused = vi.spyOn(HTMLElement.prototype, 'focus')
|
||||
|
||||
await followLink('body', 'Go to body')
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(panelRoot()).toBeNull()
|
||||
expect(
|
||||
focused.mock.contexts.some((target) =>
|
||||
(target as HTMLElement).classList.contains('ProseMirror'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('opens Site and authors and moves focus to its list for an authors suggestion', async () => {
|
||||
await followLink('author_ids', 'Go to authors')
|
||||
|
||||
expect(panelRoot()).toBeNull()
|
||||
const meta = document.body.querySelector('[data-testid="meta-section"]')!
|
||||
expect(meta.querySelector('[data-testid="meta-body"]')).not.toBeNull()
|
||||
expect(meta.contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The messages follow the params of the response', () => {
|
||||
it.each([
|
||||
[
|
||||
'title_long',
|
||||
'seo_title',
|
||||
{ length: 74, max: 60 },
|
||||
'The meta title is 74 characters. Search engines may cut it off after about 60.',
|
||||
],
|
||||
[
|
||||
'desc_short',
|
||||
'seo_description',
|
||||
{ length: 41, min: 70 },
|
||||
'The description is 41 characters. About 70 to 160 reads best in results.',
|
||||
],
|
||||
[
|
||||
'cover_alt',
|
||||
'cover_image_alt',
|
||||
{},
|
||||
'The cover image has no alt text. Add a short description of it.',
|
||||
],
|
||||
[
|
||||
'no_h',
|
||||
'body',
|
||||
{},
|
||||
'The body has no headings. H2 headings help readers scan the article.',
|
||||
],
|
||||
['title_empty', 'seo_title', {}, 'The meta title is empty.'],
|
||||
[
|
||||
'desc_long',
|
||||
'seo_description',
|
||||
{ length: 170, max: 160 },
|
||||
'The description is 170 characters. About 70 to 160 reads best in results.',
|
||||
],
|
||||
['cover_none', 'cover_image_id', {}, 'This article has no cover image.'],
|
||||
[
|
||||
'slug_long',
|
||||
'slug',
|
||||
{ length: 80, max: 75 },
|
||||
'The URL slug is 80 characters. Shorter addresses are easier to share.',
|
||||
],
|
||||
['no_au', 'author_ids', {}, 'This article has no author.'],
|
||||
['body_alt', 'body', { count: 2 }, '2 images in the body have no alt text.'],
|
||||
['body_alt', 'body', { count: 1 }, '1 image in the body has no alt text.'],
|
||||
[
|
||||
'short',
|
||||
'body',
|
||||
{ length: 40, min: 300 },
|
||||
'The body is 40 characters. Longer articles tend to do better in search.',
|
||||
],
|
||||
] as const)('renders %s with %j', async (code, field, params, message) => {
|
||||
const backend = seoBackend()
|
||||
answerChecks(backend, checkAnswer([suggestion(code, field, { ...params })]))
|
||||
await mountSeo(backend)
|
||||
await openSection()
|
||||
|
||||
expect(textOf(suggestionRows()[0])).toContain(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The check could not run', () => {
|
||||
it.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])(
|
||||
"shows Couldn't check this article. with Try again and requests the check again on %s",
|
||||
async (_name, failure) => {
|
||||
const backend = seoBackend()
|
||||
backend.responders['POST /v1/article/12/seo/check'] = failure
|
||||
await mountSeo(backend)
|
||||
const body = await openSection()
|
||||
|
||||
expect(textOf(body)).toContain("Couldn't check this article.")
|
||||
expect(textOf(body)).not.toContain('All good')
|
||||
expect(toasts()).toBe('')
|
||||
expect(patchBodies(backend)).toEqual([])
|
||||
const callsBefore = checkCalls(backend).length
|
||||
|
||||
backend.responders['POST /v1/article/12/seo/check'] = () => checkAnswer([])
|
||||
await click('Try again')
|
||||
|
||||
expect(checkCalls(backend)).toHaveLength(callsBefore + 1)
|
||||
expect(textOf(body)).toContain('All good')
|
||||
},
|
||||
)
|
||||
|
||||
it('shows the Not found editor state when the check answers 404', async () => {
|
||||
const backend = seoBackend()
|
||||
backend.responders['POST /v1/article/12/seo/check'] = failWith(404)
|
||||
await mountSeo(backend)
|
||||
|
||||
expect(document.body.textContent).toContain("This article doesn't exist.")
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { messages } from '@/i18n/messages'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
import { fixture, textOf } from './mediaTestKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
@@ -127,6 +127,9 @@ async function mountEditor(options: EditorOptions = {}) {
|
||||
if (url === '/v1/site/') {
|
||||
return reply(200, oneSite)
|
||||
}
|
||||
if (url === '/v1/article/12/seo/check') {
|
||||
return reply(200, readyCheck)
|
||||
}
|
||||
if (url === '/v1/media/?page=1&size=25') {
|
||||
return reply(200, libraryPage())
|
||||
}
|
||||
@@ -158,7 +161,10 @@ function variantRequests() {
|
||||
}
|
||||
|
||||
function writeRequests() {
|
||||
return fetchMock.mock.calls.filter(([, init]) => (init as RequestInit | undefined)?.method)
|
||||
return fetchMock.mock.calls.filter(
|
||||
([url, init]) =>
|
||||
(init as RequestInit | undefined)?.method && !String(url).endsWith('/seo/check'),
|
||||
)
|
||||
}
|
||||
|
||||
function findButton(text: string, root: ParentNode = document.body) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
@@ -29,6 +29,7 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
const routes = {
|
||||
'GET /v1/site/': () => mockResponse(200, oneSite),
|
||||
'POST /v1/article/12/seo/check': () => mockResponse(200, readyCheck),
|
||||
...handlers,
|
||||
}
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
@@ -690,7 +691,11 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
|
||||
expect(props.confirmLabel).toBe('Remove image')
|
||||
expect(props.cancelLabel).toBe('Cancel')
|
||||
expect(props.tone).toBe('neutral')
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
||||
expect(requestCalls(wrapper)).toEqual([
|
||||
'GET /v1/article/12?format=html',
|
||||
'GET /v1/site/',
|
||||
'POST /v1/article/12/seo/check',
|
||||
])
|
||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||
})
|
||||
|
||||
@@ -743,7 +748,11 @@ describe('Cancelling the confirm changes nothing', () => {
|
||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||
expect(wrapper.text()).not.toContain('Unsaved changes')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
||||
expect(requestCalls(wrapper)).toEqual([
|
||||
'GET /v1/article/12?format=html',
|
||||
'GET /v1/site/',
|
||||
'POST /v1/article/12/seo/check',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -767,7 +776,11 @@ describe('Confirming removes the image from the body only, marks the draft dirty
|
||||
title: 'Image removed',
|
||||
description: "It's still in your library.",
|
||||
})
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
||||
expect(requestCalls(wrapper)).toEqual([
|
||||
'GET /v1/article/12?format=html',
|
||||
'GET /v1/site/',
|
||||
'POST /v1/article/12/seo/check',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import router from '@/router'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||
|
||||
type Handler = () => Response
|
||||
|
||||
@@ -36,6 +36,7 @@ let wrapper: ReturnType<typeof mount> | null = null
|
||||
function stubFetch(handlers: Record<string, Handler>) {
|
||||
const routes: Record<string, Handler> = {
|
||||
'GET /v1/site/': () => reply(200, oneSite),
|
||||
'POST /v1/article/12/seo/check': () => reply(200, readyCheck),
|
||||
'GET /v1/article/12?format=html': () => reply(200, article),
|
||||
...handlers,
|
||||
}
|
||||
|
||||
@@ -65,3 +65,9 @@ export const leaFontaine: Byline = {
|
||||
}
|
||||
|
||||
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
|
||||
|
||||
export const readyCheck = {
|
||||
status: 'ready',
|
||||
suggestions: [],
|
||||
slug: { available: true, suggestion: null },
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { Article, Byline } from '@/composables/useArticles'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { SEO_KEYS } from '@/lib/articleSeo'
|
||||
import { slugify } from '@/lib/directory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
@@ -42,6 +43,14 @@ export interface MetaBackend extends Backend {
|
||||
meSlugAdjusted?: boolean
|
||||
}
|
||||
|
||||
export function readyCheck() {
|
||||
return {
|
||||
status: 'ready',
|
||||
suggestions: [],
|
||||
slug: { available: true, suggestion: null },
|
||||
}
|
||||
}
|
||||
|
||||
export const MARTA_EMAIL = 'marta@renew.example'
|
||||
const ARTICLE_ID = 12
|
||||
|
||||
@@ -146,6 +155,11 @@ function patchArticle(backend: MetaBackend, body: Record<string, unknown>) {
|
||||
if (typeof body.body === 'string') {
|
||||
backend.article.body = body.body
|
||||
}
|
||||
for (const key of SEO_KEYS) {
|
||||
if (key in body) {
|
||||
Object.assign(backend.article, { [key]: body[key] })
|
||||
}
|
||||
}
|
||||
return structuredClone(backend.article)
|
||||
}
|
||||
|
||||
@@ -206,6 +220,9 @@ function createMyAuthor(backend: MetaBackend) {
|
||||
function route(backend: MetaBackend, call: Call): unknown {
|
||||
const { method, path, query, body } = call
|
||||
const articleMatch = /^\/v1\/article\/(\d+)$/.exec(path)
|
||||
if (/^\/v1\/article\/\d+\/seo\/check$/.test(path) && method === 'POST') {
|
||||
return readyCheck()
|
||||
}
|
||||
if (articleMatch && method === 'GET') {
|
||||
return structuredClone(backend.article)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import type { Call } from './directoryTestKit'
|
||||
import {
|
||||
createMetaBackend,
|
||||
mountMeta,
|
||||
renewSites,
|
||||
textOf,
|
||||
type MetaBackend,
|
||||
type MetaMountOptions,
|
||||
} from './metaTestKit'
|
||||
import type { SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
export const EDITOR_URL = '/article/12/edit'
|
||||
export const ARTICLE_TITLE = 'How we price a bathroom renovation'
|
||||
export const ARTICLE_SLUG = 'how-we-price-a-bathroom-renovation'
|
||||
export const FIRST_SENTENCE =
|
||||
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
|
||||
export const ARTICLE_BODY = `<p>${FIRST_SENTENCE} Every project starts with a visit.</p><h2>What goes into the price</h2><p>Stripping out and making good.</p>`
|
||||
export const LONG_TITLE =
|
||||
'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
|
||||
export const NORTH_WALL = {
|
||||
id: 1,
|
||||
filename: 'north-wall.jpg',
|
||||
url: '/static/uploadedImages/north-wall.jpg',
|
||||
size: 4089446,
|
||||
content_type: 'image/jpeg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
sha256: 'a'.repeat(64),
|
||||
uploaded_by: 'Marta Alves',
|
||||
created_at: '2026-09-12T12:00:00Z',
|
||||
used_in: 1,
|
||||
duplicate: false,
|
||||
variant_count: 0,
|
||||
variant_size: 0,
|
||||
}
|
||||
export const KITCHEN_AFTER = { ...NORTH_WALL, id: 2, filename: 'kitchen-after.webp' }
|
||||
|
||||
export const READY = {
|
||||
status: 'ready',
|
||||
suggestions: [],
|
||||
slug: { available: true, suggestion: null },
|
||||
}
|
||||
|
||||
export function suggestion(
|
||||
code: SuggestionCode,
|
||||
field: SeoSuggestion['field'],
|
||||
params: Record<string, number> = {},
|
||||
): SeoSuggestion {
|
||||
return { code, field, params }
|
||||
}
|
||||
|
||||
export function checkAnswer(
|
||||
suggestions: SeoSuggestion[],
|
||||
status: 'ready' | 'suggestions' | 'hidden' = suggestions.length ? 'suggestions' : 'ready',
|
||||
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
|
||||
) {
|
||||
return { status, suggestions, slug }
|
||||
}
|
||||
|
||||
export const FOUR_SUGGESTIONS = [
|
||||
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||
suggestion('cover_alt', 'cover_image_alt'),
|
||||
suggestion('no_h', 'body'),
|
||||
suggestion('short', 'body', { length: 40, min: 300 }),
|
||||
]
|
||||
|
||||
export function renewSite(overrides: Partial<Site> = {}): Site {
|
||||
return {
|
||||
...renewSites()[0]!,
|
||||
site_name: 'Renew',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function seoBackend(
|
||||
options: {
|
||||
article?: Partial<MetaBackend['article']>
|
||||
sites?: Site[]
|
||||
check?: unknown
|
||||
role?: 'owner' | 'admin' | 'verified'
|
||||
} = {},
|
||||
) {
|
||||
const backend = createMetaBackend({
|
||||
sites: options.sites ?? [renewSite()],
|
||||
role: options.role,
|
||||
})
|
||||
Object.assign(backend.article, {
|
||||
title: ARTICLE_TITLE,
|
||||
body: ARTICLE_BODY,
|
||||
slug: ARTICLE_SLUG,
|
||||
excerpt: '',
|
||||
seo_title: '',
|
||||
seo_description: '',
|
||||
cover_image_id: null,
|
||||
cover_image_alt: '',
|
||||
social_title: '',
|
||||
social_description: '',
|
||||
social_image_id: null,
|
||||
canonical_url: '',
|
||||
noindex: false,
|
||||
...options.article,
|
||||
})
|
||||
backend.responders['POST /v1/article/12/seo/check'] = () => options.check ?? READY
|
||||
backend.responders['GET /v1/media/'] = () => ({
|
||||
items: [NORTH_WALL, KITCHEN_AFTER],
|
||||
total: 2,
|
||||
page: 1,
|
||||
size: 100,
|
||||
counts: { all: 2, unused: 0, duplicates: 0 },
|
||||
})
|
||||
return backend
|
||||
}
|
||||
|
||||
export async function mountSeo(backend: MetaBackend, options: MetaMountOptions = {}) {
|
||||
await mountMeta('editor', EDITOR_URL, { ...options, backend })
|
||||
return backend
|
||||
}
|
||||
|
||||
export function checkCalls(backend: MetaBackend) {
|
||||
return backend.calls.filter(
|
||||
(call) => call.method === 'POST' && call.path.endsWith('/seo/check'),
|
||||
)
|
||||
}
|
||||
|
||||
export function checkBodies(backend: MetaBackend) {
|
||||
return checkCalls(backend).map((call) => call.body)
|
||||
}
|
||||
|
||||
export function patchBodies(backend: MetaBackend) {
|
||||
return backend.calls
|
||||
.filter((call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path))
|
||||
.map((call) => call.body)
|
||||
}
|
||||
|
||||
export function seoCalls(backend: MetaBackend) {
|
||||
return backend.calls.filter((call) => call.method === 'GET' && call.path.endsWith('/seo'))
|
||||
}
|
||||
|
||||
export function answerChecks(backend: MetaBackend, answer: unknown | ((call: Call) => unknown)) {
|
||||
backend.responders['POST /v1/article/12/seo/check'] = (call) =>
|
||||
typeof answer === 'function' ? (answer as (call: Call) => unknown)(call) : answer
|
||||
}
|
||||
|
||||
export function section(root: ParentNode = document.body) {
|
||||
return root.querySelector<HTMLElement>('[data-testid="seo-section"]')
|
||||
}
|
||||
|
||||
export function sectionToggle(root: ParentNode = document.body) {
|
||||
return root.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!
|
||||
}
|
||||
|
||||
export function sectionSummary(root: ParentNode = document.body) {
|
||||
return root.querySelector<HTMLElement>('[data-testid="seo-summary"]')
|
||||
}
|
||||
|
||||
export function sectionBody(root: ParentNode = document.body) {
|
||||
return root.querySelector<HTMLElement>('[data-testid="seo-body"]')
|
||||
}
|
||||
|
||||
export async function openSection() {
|
||||
if (!sectionBody()) {
|
||||
sectionToggle().click()
|
||||
await flushPromises()
|
||||
}
|
||||
return sectionBody()!
|
||||
}
|
||||
|
||||
export function panelRoot() {
|
||||
return (
|
||||
document.body
|
||||
.querySelector<HTMLElement>('[data-testid="seo-panel-suggestions"]')
|
||||
?.closest('[role="dialog"]') ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export async function openPanel() {
|
||||
await openSection()
|
||||
const edit = document.body.querySelector<HTMLElement>('[data-testid="seo-edit"]')!
|
||||
edit.focus()
|
||||
edit.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushPromises()
|
||||
return panelRoot()!
|
||||
}
|
||||
|
||||
export async function openDisclosure(title: string) {
|
||||
const toggle = Array.from(
|
||||
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||
).find((button) => textOf(button).startsWith(title))!
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function inputByPlaceholder(root: ParentNode, placeholder: string) {
|
||||
return root.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
||||
`[placeholder="${placeholder}"]`,
|
||||
)!
|
||||
}
|
||||
|
||||
export function fieldByLabel(root: ParentNode, label: string) {
|
||||
const element = Array.from(root.querySelectorAll('label')).find((candidate) =>
|
||||
textOf(candidate).startsWith(label),
|
||||
)
|
||||
const id = element?.getAttribute('for')
|
||||
return id ? (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement) : null
|
||||
}
|
||||
|
||||
export function counterTexts(root: ParentNode) {
|
||||
return Array.from(root.querySelectorAll('[data-testid="soft-counter"]')).map((node) =>
|
||||
textOf(node),
|
||||
)
|
||||
}
|
||||
|
||||
export function saveChangesButton(root: ParentNode) {
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(candidate) => textOf(candidate) === 'Save changes' || textOf(candidate) === 'Saving…',
|
||||
)!
|
||||
}
|
||||
@@ -129,6 +129,50 @@ const extraSources: Record<string, Source[]> = {
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Editor: search and sharing',
|
||||
states: [
|
||||
'Collapsed: ready',
|
||||
'Collapsed: suggestions',
|
||||
'Collapsed: hidden',
|
||||
'Open: ready',
|
||||
'Open: suggestions',
|
||||
'Open: checking',
|
||||
'Open: hidden',
|
||||
'Open: other site',
|
||||
'Panel: defaults',
|
||||
'Panel: filled in',
|
||||
'Panel: outside the soft ranges',
|
||||
'Panel: slug edited',
|
||||
'Panel: slug taken',
|
||||
'Panel: cover without alt',
|
||||
'Panel: choose cover',
|
||||
'Panel: social sharing open',
|
||||
'Panel: social customised',
|
||||
'Panel: advanced, invalid canonical',
|
||||
'Panel: hidden from search',
|
||||
'Panel: mobile search preview',
|
||||
'Panel: checking',
|
||||
'Panel: saving',
|
||||
'Panel: save failed',
|
||||
'Saved (toast)',
|
||||
],
|
||||
alias: { 'Saved (toast)': 'Saved' },
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Editor: what the frontend gets',
|
||||
states: [
|
||||
'Formatted',
|
||||
'Raw JSON',
|
||||
'Copied',
|
||||
'Draft article',
|
||||
'Dialog: loading',
|
||||
'Dialog: error',
|
||||
],
|
||||
alias: { 'Dialog: loading': 'Loading', 'Dialog: error': 'Error' },
|
||||
},
|
||||
],
|
||||
profile: [
|
||||
{
|
||||
|
||||
@@ -309,6 +309,8 @@ function updateApi(
|
||||
|
||||
const editorNow = '2026-09-26T11:02:00Z'
|
||||
|
||||
const seoReady = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
|
||||
|
||||
const bathroomBody =
|
||||
"<p>Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>" +
|
||||
'<h2>What goes into the price</h2>' +
|
||||
@@ -371,6 +373,7 @@ function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
|
||||
'GET /v1/media/': { status: 200, body: emptyLibrary },
|
||||
'GET /v1/site/': boardSiteList(),
|
||||
'GET /v1/author/': boardAuthorList(),
|
||||
'POST /v1/article/12/seo/check': { status: 200, body: seoReady },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -5419,6 +5422,470 @@ const authorsStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
const seoDescription =
|
||||
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
|
||||
const seoLongTitle = 'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
|
||||
const seoCoverAlt = 'A tiled bathroom wall during renovation'
|
||||
const seoFilledTitle = 'What a bathroom renovation costs'
|
||||
const seoFilledDescription =
|
||||
'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.'
|
||||
const seoFilledExcerpt = 'How we price a bathroom renovation, from the survey to the final invoice.'
|
||||
|
||||
const seoBody =
|
||||
`<p>${seoDescription} We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>` +
|
||||
'<h2>What goes into the price</h2>' +
|
||||
'<ul><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>' +
|
||||
'<p>We don’t add a margin on materials you choose yourself.</p>'
|
||||
|
||||
function seoSuggestion(code: string, field: string, params: Record<string, number> = {}) {
|
||||
return { code, field, params }
|
||||
}
|
||||
|
||||
const seoSuggestions: Record<string, ReturnType<typeof seoSuggestion>> = {
|
||||
title_long: seoSuggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||
desc_short: seoSuggestion('desc_short', 'seo_description', { length: 41, min: 70 }),
|
||||
cover_alt: seoSuggestion('cover_alt', 'cover_image_alt'),
|
||||
cover_none: seoSuggestion('cover_none', 'cover_image_id'),
|
||||
no_h: seoSuggestion('no_h', 'body'),
|
||||
short: seoSuggestion('short', 'body', { length: 120, min: 300 }),
|
||||
}
|
||||
|
||||
function seoCheck(
|
||||
codes: string[],
|
||||
status = codes.length ? 'suggestions' : 'ready',
|
||||
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
|
||||
): ApiMock {
|
||||
return {
|
||||
status: 200,
|
||||
body: { status, suggestions: codes.map((code) => seoSuggestions[code]), slug },
|
||||
}
|
||||
}
|
||||
|
||||
const seoTakenSlug = { available: false, suggestion: 'bathroom-costs-2' }
|
||||
|
||||
function seoSite(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...directorySites[0]!,
|
||||
default_social_image_id: 1,
|
||||
site_name: 'Renew',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function seoJournal() {
|
||||
return {
|
||||
...directorySites[1]!,
|
||||
site_name: 'Journal',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
}
|
||||
}
|
||||
|
||||
function seoArticle(overrides: Record<string, unknown> = {}, body = seoBody) {
|
||||
return {
|
||||
...editorArticle,
|
||||
body,
|
||||
slug: 'how-we-price-a-bathroom-renovation',
|
||||
excerpt: '',
|
||||
seo_title: '',
|
||||
seo_description: '',
|
||||
cover_image_id: null,
|
||||
cover_image_alt: '',
|
||||
social_title: '',
|
||||
social_description: '',
|
||||
social_image_id: null,
|
||||
canonical_url: '',
|
||||
noindex: false,
|
||||
seo_dismissed: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const seoApiResponse = {
|
||||
title: 'How we price a bathroom renovation | Renew',
|
||||
description: seoDescription,
|
||||
canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||
robots: 'index, follow',
|
||||
og: {
|
||||
type: 'article',
|
||||
title: 'How we price a bathroom renovation | Renew',
|
||||
description: seoDescription,
|
||||
url: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||
site_name: 'Renew',
|
||||
image: 'https://renew.example/media/north-wall.jpg',
|
||||
image_alt: seoCoverAlt,
|
||||
locale: 'en',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'How we price a bathroom renovation | Renew',
|
||||
description: seoDescription,
|
||||
image: 'https://renew.example/media/north-wall.jpg',
|
||||
image_alt: seoCoverAlt,
|
||||
},
|
||||
image: {
|
||||
url: 'https://renew.example/media/north-wall.jpg',
|
||||
alt: seoCoverAlt,
|
||||
source: 'cover_image',
|
||||
},
|
||||
authors: [
|
||||
{
|
||||
name: 'Inès Duarte',
|
||||
url: 'https://ines-duarte.example',
|
||||
same_as: ['https://www.linkedin.com/in/ines-duarte'],
|
||||
},
|
||||
],
|
||||
published_at: '2026-03-12T09:14:00Z',
|
||||
modified_at: '2026-10-15T11:02:00Z',
|
||||
fallbacks: { title: 'title', description: 'body', image: 'cover', canonical: 'site' },
|
||||
json_ld: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'How we price a bathroom renovation',
|
||||
description: seoDescription,
|
||||
image: ['https://renew.example/media/north-wall.jpg'],
|
||||
author: [
|
||||
{
|
||||
'@type': 'Person',
|
||||
name: 'Inès Duarte',
|
||||
url: 'https://ines-duarte.example',
|
||||
sameAs: ['https://www.linkedin.com/in/ines-duarte'],
|
||||
},
|
||||
],
|
||||
datePublished: '2026-03-12T09:14:00Z',
|
||||
dateModified: '2026-10-15T11:02:00Z',
|
||||
mainEntityOfPage: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||
},
|
||||
}
|
||||
|
||||
interface SeoOptions {
|
||||
article?: Record<string, unknown>
|
||||
body?: string
|
||||
check?: ApiMock
|
||||
sites?: unknown[]
|
||||
overrides?: Record<string, ApiMock>
|
||||
library?: boolean
|
||||
}
|
||||
|
||||
function seoApi(options: SeoOptions = {}): Record<string, ApiMock> {
|
||||
return editorApi({
|
||||
'GET /v1/article/12': {
|
||||
status: 200,
|
||||
body: seoArticle(options.article, options.body),
|
||||
},
|
||||
'GET /v1/site/': { status: 200, body: options.sites ?? [seoSite()] },
|
||||
'POST /v1/article/12/seo/check': options.check ?? { status: 200, body: seoReady },
|
||||
'GET /v1/media/': { status: 200, bodyFor: avatarPickerList },
|
||||
'GET /v1/image-variants': { status: 403 },
|
||||
'GET /v1/article/12/seo': { status: 200, body: seoApiResponse },
|
||||
...(options.overrides ?? {}),
|
||||
})
|
||||
}
|
||||
|
||||
async function seoOpenSection(page: Page) {
|
||||
if (isNarrow(page)) {
|
||||
await page.getByRole('button', { name: 'Details', exact: true }).click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
}
|
||||
const toggle = page.locator('[data-testid="seo-toggle"]:visible')
|
||||
await toggle.waitFor()
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click()
|
||||
}
|
||||
await page.locator('[data-testid="seo-body"]:visible').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function seoOpenPanel(page: Page) {
|
||||
await seoOpenSection(page)
|
||||
await page.locator('[data-testid="seo-edit"]:visible').click()
|
||||
await page.locator('[data-testid="seo-panel-suggestions"]').waitFor()
|
||||
await page.waitForTimeout(250)
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function seoScrollPanel(page: Page, top: number) {
|
||||
await page.evaluate((offset) => {
|
||||
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
|
||||
body.scrollTop = offset
|
||||
})
|
||||
}, top)
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function seoOpenDisclosure(page: Page, title: string) {
|
||||
await page.locator('[data-testid="seo-disclosure-toggle"]', { hasText: title }).first().click()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function seoSidebarState(options: SeoOptions, open: boolean): VisualState {
|
||||
return {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: seoApi(options),
|
||||
steps: async (page) => {
|
||||
if (open) {
|
||||
await seoOpenSection(page)
|
||||
return
|
||||
}
|
||||
if (isNarrow(page)) {
|
||||
await page.getByRole('button', { name: 'Details', exact: true }).click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function seoPanelState(
|
||||
options: SeoOptions,
|
||||
steps: (page: Page) => Promise<void> = async () => {},
|
||||
scrollY = 0,
|
||||
): VisualState {
|
||||
return {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: seoApi(options),
|
||||
steps: async (page) => {
|
||||
await seoOpenPanel(page)
|
||||
await steps(page)
|
||||
await seoScrollPanel(page, scrollY)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function seoTypeInPanel(page: Page, label: string, value: string) {
|
||||
await page.getByRole('dialog').getByLabel(label).first().fill(value)
|
||||
}
|
||||
|
||||
const seoFilled = {
|
||||
seo_title: seoFilledTitle,
|
||||
seo_description: seoFilledDescription,
|
||||
excerpt: seoFilledExcerpt,
|
||||
cover_image_id: 1,
|
||||
cover_image_alt: seoCoverAlt,
|
||||
}
|
||||
|
||||
const seoCoverNoAlt = { cover_image_id: 1, cover_image_alt: '' }
|
||||
|
||||
const seoSocialCustom = {
|
||||
cover_image_id: 1,
|
||||
cover_image_alt: seoCoverAlt,
|
||||
social_title: 'Bathroom costs, explained',
|
||||
social_description: 'A plain guide to what drives the price.',
|
||||
social_image_id: 2,
|
||||
}
|
||||
|
||||
async function seoOpenApiDialog(page: Page) {
|
||||
await seoOpenSection(page)
|
||||
await page.locator('[data-testid="seo-preview"]:visible').click()
|
||||
await page.locator('[data-testid="api-preview"]').waitFor()
|
||||
await page.waitForTimeout(250)
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function seoSaveFromPanel(page: Page) {
|
||||
await seoTypeInPanel(page, 'Meta title', seoFilledTitle)
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Save changes', exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
const editorSeoStates: Record<string, VisualState> = {
|
||||
'Collapsed: ready': seoSidebarState({}, false),
|
||||
'Collapsed: suggestions': seoSidebarState(
|
||||
{ check: seoCheck(['title_long', 'cover_alt']) },
|
||||
false,
|
||||
),
|
||||
'Collapsed: hidden': seoSidebarState(
|
||||
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
|
||||
false,
|
||||
),
|
||||
'Open: ready': seoSidebarState({}, true),
|
||||
'Open: suggestions': seoSidebarState(
|
||||
{ check: seoCheck(['title_long', 'cover_alt', 'no_h', 'short']) },
|
||||
true,
|
||||
),
|
||||
'Open: checking': seoSidebarState({ check: { status: 200, hang: true } }, true),
|
||||
'Open: hidden': seoSidebarState(
|
||||
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
|
||||
true,
|
||||
),
|
||||
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
|
||||
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
|
||||
'Panel: filled in': seoPanelState({ article: seoFilled }),
|
||||
'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(
|
||||
{
|
||||
check: {
|
||||
status: 200,
|
||||
bodyFor: (_url, call) =>
|
||||
seoCheck([], 'ready', call >= 3 ? seoTakenSlug : undefined).body,
|
||||
},
|
||||
},
|
||||
async (page) => {
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Edit slug' }).click()
|
||||
const slug = page.getByRole('dialog').getByLabel('URL slug')
|
||||
await slug.fill('bathroom-costs')
|
||||
await slug.blur()
|
||||
await page.getByText('is already used on Renew.').waitFor()
|
||||
},
|
||||
),
|
||||
'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 })
|
||||
.first()
|
||||
.click()
|
||||
await waitForPickerTiles(page)
|
||||
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'Panel: social sharing open': seoPanelState(
|
||||
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
|
||||
(page) => seoOpenDisclosure(page, 'Social sharing'),
|
||||
560,
|
||||
),
|
||||
'Panel: social customised': seoPanelState(
|
||||
{ article: seoSocialCustom },
|
||||
(page) => seoOpenDisclosure(page, 'Social sharing'),
|
||||
560,
|
||||
),
|
||||
'Panel: advanced, invalid canonical': seoPanelState(
|
||||
{},
|
||||
async (page) => {
|
||||
await seoOpenDisclosure(page, 'Advanced')
|
||||
const canonical = page.getByRole('dialog').getByLabel('Canonical URL')
|
||||
await canonical.fill('renew.example/costs')
|
||||
await canonical.blur()
|
||||
await page.getByText('Enter a full address starting with https://').waitFor()
|
||||
},
|
||||
640,
|
||||
),
|
||||
'Panel: hidden from search': seoPanelState(
|
||||
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
|
||||
async () => {},
|
||||
700,
|
||||
),
|
||||
'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(
|
||||
{ overrides: { 'PATCH /v1/article/12': { status: 200, hang: true } } },
|
||||
async (page) => {
|
||||
await seoSaveFromPanel(page)
|
||||
await page.getByRole('dialog').getByText('Saving…').first().waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Panel: save failed': seoPanelState(
|
||||
{ overrides: { 'PATCH /v1/article/12': { status: 500 } } },
|
||||
async (page) => {
|
||||
await seoSaveFromPanel(page)
|
||||
await page.getByText("Couldn't save search and sharing.").waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Saved (toast)': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: seoApi({ overrides: { 'PATCH /v1/article/12': { status: 200, body: seoArticle() } } }),
|
||||
steps: async (page) => {
|
||||
await seoOpenPanel(page)
|
||||
await seoSaveFromPanel(page)
|
||||
await page.getByText('Search and sharing saved').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
Formatted: {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: seoApi({ article: { is_online: true } }),
|
||||
steps: seoOpenApiDialog,
|
||||
},
|
||||
'Raw JSON': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: seoApi({ article: { is_online: true } }),
|
||||
steps: async (page) => {
|
||||
await seoOpenApiDialog(page)
|
||||
await page.getByRole('tab', { name: 'Raw JSON', exact: true }).click({ force: true })
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
Copied: {
|
||||
path: '/article/12/edit',
|
||||
origin: integrationOrigin,
|
||||
now: editorNow,
|
||||
api: seoApi({ article: { is_online: true } }),
|
||||
steps: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: async () => undefined },
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
await seoOpenApiDialog(page)
|
||||
await page.getByRole('tab', { name: 'Raw JSON', exact: true }).click({ force: true })
|
||||
await page
|
||||
.getByRole('button', { name: 'Copy JSON', exact: true })
|
||||
.click({ force: true })
|
||||
await page.getByRole('button', { name: 'Copied', exact: true }).waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Draft article': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: seoApi({ article: { is_online: false } }),
|
||||
steps: seoOpenApiDialog,
|
||||
},
|
||||
'Dialog: loading': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: seoApi({
|
||||
article: { is_online: true },
|
||||
overrides: { 'GET /v1/article/12/seo': { status: 200, hang: true } },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await seoOpenSection(page)
|
||||
await page.locator('[data-testid="seo-preview"]:visible').click()
|
||||
await page.locator('[data-testid="api-preview-loading"]').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Dialog: error': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: seoApi({
|
||||
article: { is_online: true },
|
||||
overrides: { 'GET /v1/article/12/seo': { status: 500 } },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await seoOpenSection(page)
|
||||
await page.locator('[data-testid="seo-preview"]:visible').click()
|
||||
await page.locator('[data-testid="api-preview-error"]').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
sites: sitesStates,
|
||||
authors: authorsStates,
|
||||
@@ -6273,6 +6740,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
...editorSeoStates,
|
||||
'Open: one site, one author': metaState({}, async () => {}),
|
||||
'Open: several sites': metaState({ sites: 4, siteId: 2 }, async () => {}),
|
||||
'Site select open': metaState({ sites: 4, siteId: 2 }, openSiteSelect),
|
||||
|
||||
Reference in new issue
Block a user