feat: public API, SEO and existing surfaces
This commit is contained in:
1 parent
45fbbc33b1
commit
c390fcfe8d
103 files changed
+4476
-231
No files matched your search
@@ -625,3 +625,48 @@ describe('Roles sync: the six role events in EN and FR', () => {
|
||||
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sentences name the language of an article version', () => {
|
||||
const actions = ['create', 'edit', 'publish', 'unpublish', 'delete']
|
||||
const english: Record<string, string> = {
|
||||
create: 'Created French version of "Spring release"',
|
||||
edit: 'Edited French version of "Spring release"',
|
||||
publish: 'Published French version of "Spring release"',
|
||||
unpublish: 'Unpublished French version of "Spring release"',
|
||||
delete: 'Deleted French version of "Spring release"',
|
||||
}
|
||||
|
||||
it.each(actions)('names French in article.%s', (action) => {
|
||||
const text = composeSentence(item({ action: `article.${action}`, params: { lang: 'fr' } }))
|
||||
.map((segment) => segment.text)
|
||||
.join('')
|
||||
|
||||
expect(text).toBe(english[action])
|
||||
})
|
||||
|
||||
it('reads in French from the same params', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const text = composeSentence(item({ params: { lang: 'fr' } }))
|
||||
.map((segment) => segment.text)
|
||||
.join('')
|
||||
|
||||
expect(text).toBe('Version en français publiée : « Spring release »')
|
||||
})
|
||||
|
||||
it('names the failure of a version', () => {
|
||||
const text = composeSentence(item({ outcome: 'failed', params: { lang: 'de' } }))
|
||||
.map((segment) => segment.text)
|
||||
.join('')
|
||||
|
||||
expect(text).toBe('Failed to publish German version of "Spring release"')
|
||||
})
|
||||
|
||||
it('keeps the sentence of an event without a language', () => {
|
||||
const text = composeSentence(item({ params: {} }))
|
||||
.map((segment) => segment.text)
|
||||
.join('')
|
||||
|
||||
expect(text).toBe('Published the article Spring release')
|
||||
})
|
||||
})
|
||||
@@ -65,9 +65,9 @@ describe('index', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('holds 89 endpoints: 43 documented, 40 session only and 6 public auth', () => {
|
||||
expect(index.endpoints).toHaveLength(89)
|
||||
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(43)
|
||||
it('holds 91 endpoints: 45 documented, 40 session only and 6 public auth', () => {
|
||||
expect(index.endpoints).toHaveLength(91)
|
||||
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(45)
|
||||
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(40)
|
||||
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
|
||||
})
|
||||
@@ -202,7 +202,7 @@ describe('matching', () => {
|
||||
describe('custom content types', () => {
|
||||
it.each([
|
||||
['media', 16, 1],
|
||||
['404', 24, 1],
|
||||
['404', 26, 1],
|
||||
['login', 1, 1],
|
||||
['entry', 7, 1],
|
||||
['capacity', 2, 0],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sizeLabel } from '@/lib/activityRetention'
|
||||
import { formatSize } from '@/lib/mediaFormat'
|
||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||
import { causeText } from '@/lib/variants/variantCauses'
|
||||
import { nameInSentence } from '@/lib/storyList'
|
||||
|
||||
export interface SentenceSegment {
|
||||
text: string
|
||||
@@ -22,6 +23,34 @@ const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
|
||||
const FRENCH_ELIDING_ACTIONS = ['user.change_role', 'user.remove']
|
||||
const FRENCH_VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
||||
|
||||
const LANGUAGE_CATALOGUE: Record<string, Template> = {
|
||||
'article.create': {
|
||||
success: 'Created {0} version of "{1}"',
|
||||
failed: 'Failed to create {0} version of "{1}"',
|
||||
},
|
||||
'article.edit': {
|
||||
success: 'Edited {0} version of "{1}"',
|
||||
failed: 'Failed to edit {0} version of "{1}"',
|
||||
},
|
||||
'article.publish': {
|
||||
success: 'Published {0} version of "{1}"',
|
||||
failed: 'Failed to publish {0} version of "{1}"',
|
||||
},
|
||||
'article.unpublish': {
|
||||
success: 'Unpublished {0} version of "{1}"',
|
||||
failed: 'Failed to unpublish {0} version of "{1}"',
|
||||
},
|
||||
'article.delete': {
|
||||
success: 'Deleted {0} version of "{1}"',
|
||||
failed: 'Failed to delete {0} version of "{1}"',
|
||||
},
|
||||
}
|
||||
|
||||
function versionLanguage(item: ActivityItem) {
|
||||
const tag = item.params.lang
|
||||
return typeof tag === 'string' && tag !== '' && item.action in LANGUAGE_CATALOGUE ? tag : null
|
||||
}
|
||||
|
||||
const CATALOGUE: Record<string, Template> = {
|
||||
'article.create': {
|
||||
success: 'Created the article {0}',
|
||||
@@ -317,6 +346,10 @@ function reassignParts(item: ActivityItem): SentenceParts {
|
||||
}
|
||||
|
||||
function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
if (versionLanguage(item) !== null) {
|
||||
const named = LANGUAGE_CATALOGUE[item.action]!
|
||||
return item.outcome === 'failed' ? (named.failed ?? named.success) : named.success
|
||||
}
|
||||
const template = CATALOGUE[item.action]
|
||||
if (!template) return null
|
||||
if (item.action === 'role.reassign') return reassignParts(item).template
|
||||
@@ -339,6 +372,10 @@ function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
}
|
||||
|
||||
function slotValues(item: ActivityItem, template: MessageKey) {
|
||||
const tag = versionLanguage(item)
|
||||
if (tag !== null) {
|
||||
return [nameInSentence(tag), TARGET_SLOT]
|
||||
}
|
||||
if (item.action === 'role.reassign') {
|
||||
return reassignParts(item).values
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { locale, t } from '@/i18n'
|
||||
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
import { nameInSentence } from '@/lib/storyList'
|
||||
|
||||
export interface LocalizedSeoContext {
|
||||
lang: string
|
||||
siteName: string
|
||||
siteKey: string
|
||||
pattern: string | null
|
||||
defaultLang: string
|
||||
disabledLangs: string[]
|
||||
published: boolean
|
||||
socialState: string
|
||||
resolved: ResolvedSeo | null
|
||||
}
|
||||
|
||||
export function pageLanguage(context: LocalizedSeoContext) {
|
||||
return languageName(context.lang, locale.value)
|
||||
}
|
||||
|
||||
export function breadcrumbOf(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||
return [parsed.host, ...segments].join(' › ')
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalNote(context: LocalizedSeoContext) {
|
||||
if (context.resolved?.fallbacks.canonical === 'canonical_url') {
|
||||
return t('Set by hand as a canonical override on this version.')
|
||||
}
|
||||
return t(
|
||||
'Built from the Public URL pattern {0}. Each language points to itself unless you set a canonical override.',
|
||||
[context.pattern ?? ''],
|
||||
)
|
||||
}
|
||||
|
||||
export function disabledNote(context: LocalizedSeoContext) {
|
||||
if (context.disabledLangs.length === 0) {
|
||||
return t('Drafts are left out.')
|
||||
}
|
||||
const names = context.disabledLangs.map((tag) => nameInSentence(tag))
|
||||
const list = names.join(', ')
|
||||
return names.length === 1
|
||||
? t('Drafts and the disabled {0} language are left out.', [list])
|
||||
: t('Drafts and the disabled languages {0} are left out.', [list])
|
||||
}
|
||||
|
||||
export function socialLocaleLine(context: LocalizedSeoContext) {
|
||||
const value = context.resolved?.og.locale ?? ''
|
||||
if (!value) {
|
||||
return t('Social locale not set')
|
||||
}
|
||||
return context.socialState === 'derived' ? t('Derived: {0}', [value]) : t('Set: {0}', [value])
|
||||
}
|
||||
|
||||
export function socialLocaleNote(context: LocalizedSeoContext) {
|
||||
const name = pageLanguage(context)
|
||||
if (!context.resolved?.og.locale) {
|
||||
return t(
|
||||
'{0} has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in {1} languages.',
|
||||
[name, context.siteName],
|
||||
)
|
||||
}
|
||||
if (context.socialState === 'derived') {
|
||||
return t(
|
||||
'Taken from the region of the language code or of the site locale. An owner can set another in {0} languages.',
|
||||
[context.siteName],
|
||||
)
|
||||
}
|
||||
return t(
|
||||
'Set by an owner for {0} in {1} languages. A region is never guessed from the language.',
|
||||
[name, context.siteName],
|
||||
)
|
||||
}
|
||||
|
||||
export function titleHelp(context: LocalizedSeoContext) {
|
||||
const source = context.resolved?.fallbacks.title
|
||||
return source === 'title' ? t('Fallback: {0} title', [nameInSentence(context.lang)]) : ''
|
||||
}
|
||||
|
||||
export function descriptionHelp(context: LocalizedSeoContext) {
|
||||
const fallbacks = context.resolved?.fallbacks
|
||||
if (!fallbacks) {
|
||||
return ''
|
||||
}
|
||||
const name = nameInSentence(context.lang)
|
||||
switch (fallbacks.description) {
|
||||
case 'excerpt':
|
||||
return t('Fallback: {0} excerpt', [name])
|
||||
case 'body':
|
||||
return t('Fallback: {0} body', [name])
|
||||
case 'site':
|
||||
return t('Fallback: {0} site description', [name])
|
||||
case 'none':
|
||||
return fallbacks.site_text_skipped
|
||||
? t('No fallback. Empty. {0} site default not used. The tag is left out.', [
|
||||
nameInSentence(context.defaultLang),
|
||||
])
|
||||
: t('No fallback')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function descriptionBadge(context: LocalizedSeoContext) {
|
||||
const fallbacks = context.resolved?.fallbacks
|
||||
if (fallbacks?.description === 'site') {
|
||||
return t('Uses site text')
|
||||
}
|
||||
return fallbacks?.site_text_skipped ? t('Tag left out') : ''
|
||||
}
|
||||
|
||||
export function altHelp() {
|
||||
return t('Written per language.')
|
||||
}
|
||||
@@ -2,15 +2,23 @@ import { t } from '@/i18n'
|
||||
import type { MediaUsageItem, UsageState } from '@/composables/useMedia'
|
||||
import { describeCell, plainCellText } from '@/lib/entryCells'
|
||||
import { pluralArticles } from '@/lib/mediaFormat'
|
||||
import { nameInSentence } from '@/lib/storyList'
|
||||
|
||||
const ENTRY_ROWS_SHOWN = 3
|
||||
const ARTICLE_TYPE = 'article'
|
||||
const ENTRY_TYPE = 'entry'
|
||||
|
||||
export interface UsageVersion {
|
||||
id: number
|
||||
lang: string
|
||||
published: boolean
|
||||
}
|
||||
|
||||
export interface UsageArticle {
|
||||
id: number
|
||||
title: string
|
||||
published: boolean
|
||||
versions: UsageVersion[]
|
||||
}
|
||||
|
||||
export interface UsageEntry {
|
||||
@@ -28,6 +36,7 @@ export interface UsageOther {
|
||||
|
||||
export interface UsageSplit {
|
||||
articles: UsageArticle[]
|
||||
showLanguages: boolean
|
||||
others: UsageOther[]
|
||||
entries: UsageEntry[]
|
||||
entryCount: number
|
||||
@@ -72,14 +81,41 @@ function toEntry(item: MediaUsageItem): UsageEntry {
|
||||
}
|
||||
}
|
||||
|
||||
export function splitUsage(items: MediaUsageItem[]): UsageSplit {
|
||||
const articles = items
|
||||
.filter((item) => item.type === undefined || item.type === ARTICLE_TYPE)
|
||||
.map((item) => ({
|
||||
function groupArticles(items: MediaUsageItem[]): UsageArticle[] {
|
||||
const groups = new Map<string, UsageArticle>()
|
||||
for (const item of items) {
|
||||
const key = item.lang && item.story_id ? `story-${item.story_id}` : `article-${item.id}`
|
||||
const version = {
|
||||
id: item.id,
|
||||
lang: item.lang ?? '',
|
||||
published: item.published === true,
|
||||
}
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.versions.push(version)
|
||||
existing.published = existing.published || version.published
|
||||
continue
|
||||
}
|
||||
groups.set(key, {
|
||||
id: item.id,
|
||||
title: item.title ?? '',
|
||||
published: item.published === true,
|
||||
}))
|
||||
published: version.published,
|
||||
versions: [version],
|
||||
})
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
function showsLanguages(articles: UsageArticle[]) {
|
||||
const tags = new Set(articles.flatMap((article) => article.versions.map((v) => v.lang)))
|
||||
tags.delete('')
|
||||
return tags.size > 1 || articles.some((article) => article.versions.length > 1)
|
||||
}
|
||||
|
||||
export function splitUsage(items: MediaUsageItem[]): UsageSplit {
|
||||
const articles = groupArticles(
|
||||
items.filter((item) => item.type === undefined || item.type === ARTICLE_TYPE),
|
||||
)
|
||||
const entryItems = items.filter((item) => item.type === ENTRY_TYPE)
|
||||
const others = items
|
||||
.filter((item) => item.type === 'author' || item.type === 'site')
|
||||
@@ -90,6 +126,7 @@ export function splitUsage(items: MediaUsageItem[]): UsageSplit {
|
||||
}))
|
||||
return {
|
||||
articles,
|
||||
showLanguages: showsLanguages(articles),
|
||||
others,
|
||||
entries: entryItems.slice(0, ENTRY_ROWS_SHOWN).map(toEntry),
|
||||
entryCount: entryItems.length,
|
||||
@@ -155,3 +192,10 @@ export function deleteCopy(filename: string, usedIn: number, state: UsageState |
|
||||
split: known ? split : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function usageLanguages(article: UsageArticle) {
|
||||
return article.versions
|
||||
.filter((version) => version.lang !== '')
|
||||
.map((version) => nameInSentence(version.lang))
|
||||
.join(', ')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const PUBLIC_LOOKUP_ROUTE = 'GET /v1/public/site/{key}/lang/{lang}/article/{slug}'
|
||||
export const PUBLIC_STORY_ROUTE =
|
||||
'GET /v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default'
|
||||
+18
-1
@@ -54,6 +54,7 @@ export interface ResolvedSeo {
|
||||
url: string
|
||||
site_name: string
|
||||
locale: string
|
||||
locale_alternates?: string[]
|
||||
}
|
||||
twitter: {
|
||||
card: string
|
||||
@@ -66,6 +67,22 @@ export interface ResolvedSeo {
|
||||
authors: { name: string; url: string; same_as: string[] }[]
|
||||
published_at: string | null
|
||||
modified_at: string
|
||||
fallbacks: { title: string; description: string; image: string; canonical: string }
|
||||
fallbacks: {
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
canonical: string
|
||||
site_text_skipped?: boolean
|
||||
}
|
||||
json_ld: Record<string, unknown>
|
||||
lang?: string
|
||||
alternates?: SeoAlternate[]
|
||||
}
|
||||
|
||||
export interface SeoAlternate {
|
||||
id: number
|
||||
lang: string
|
||||
story_id: number
|
||||
slug: string
|
||||
url: string | null
|
||||
}
|
||||
Reference in new issue
Block a user