feat: public API, SEO and existing surfaces

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

No files matched your search

@@ -112,7 +112,9 @@ describe('Existing screens are unaffected', () => {
'localization-list',
'localization-editor',
'localization-conflicts-moves',
'localization-new-article',
'localization-new-article',
'localization-metadata-api',
'localization-surfaces',
])
})
})
+183
View File
@@ -0,0 +1,183 @@
import type { Page } from 'playwright'
import { accessFor } from '../src/test/access'
import { siteLanguagesBody } from '../src/test/storyLists'
import {
articleBody,
fails,
narrow,
ok,
press,
settle,
site,
state,
type Setup,
} from './editorLocalizationStates'
import type { ApiMock, VisualState } from './states'
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
const FRENCH_SLUG = 'comment-nous-chiffrons-une-renovation'
const FRENCH_TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
const alternates = [
{
id: 10,
lang: 'en',
story_id: 5,
slug: 'how-we-price-a-bathroom-renovation',
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
},
{
id: 11,
lang: 'de',
story_id: 5,
slug: 'so-kalkulieren-wir-eine-badsanierung',
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
},
]
function resolved(locale: string, canonical = `https://journal.example.com/fr/${FRENCH_SLUG}`) {
return {
title: `${FRENCH_TITLE} | Journal`,
description: '',
canonical_url: canonical,
robots: 'index, follow',
og: {
type: 'article',
title: '',
description: '',
image: '',
image_alt: '',
url: canonical,
site_name: 'Journal',
locale,
locale_alternates: [],
},
twitter: { card: 'summary' },
image: { url: '', alt: '', source: 'none' },
authors: [],
published_at: null,
modified_at: '2026-10-14T09:12:00Z',
fallbacks: {
title: 'title',
description: 'none',
image: 'none',
canonical: 'pattern',
site_text_skipped: true,
},
json_ld: {},
lang: 'fr',
alternates: canonical.includes('/fr/') ? alternates : [],
}
}
function languages(social: boolean) {
const body = siteLanguagesBody('journal')
return {
...body,
languages: body.languages.map((row) =>
row.lang === 'fr'
? {
...row,
social_locale: social ? 'fr_FR' : null,
social_locale_state: social ? 'set' : 'not_set',
}
: row,
),
}
}
function frenchArticle(setup: Setup) {
return { ...articleBody(setup, 'fr'), slug: FRENCH_SLUG, title: FRENCH_TITLE }
}
export async function openSeoPanel(page: Page) {
if (narrow(page)) {
await page
.getByRole('button', { name: /^Details/ })
.first()
.click()
await page.getByRole('dialog').waitFor()
}
const body = page.locator('[data-testid="seo-body"]:visible')
if (!(await body.count())) {
await page.locator('[data-testid="seo-toggle"]:visible').first().click()
}
await page.locator('[data-testid="seo-edit"]:visible').first().click()
await page.locator('[data-testid="seo-language-cards"]').waitFor()
await settle(page)
}
export function seoState(
setup: Setup,
pattern: string | null,
locale: string,
social: boolean,
steps: (page: Page) => Promise<void> = openSeoPanel,
): VisualState {
const canonical = pattern
? `https://journal.example.com/fr/${FRENCH_SLUG}`
: `https://journal.example.com/${FRENCH_SLUG}`
const overrides: Record<string, ApiMock> = {
'GET /v1/site/': ok([{ ...site, public_url_pattern: pattern }]),
'GET /v1/auth/me': {
status: 200,
body: {
id: 7,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
access: accessFor('owner'),
},
},
'GET /v1/article/12': ok(frenchArticle(setup)),
'GET /v1/article/12/seo': ok(resolved(locale, canonical)),
'POST /v1/article/12/seo/check': ok({
status: 'ready',
suggestions: [],
slug: { available: true, suggestion: null },
}),
}
return state({ languages: languages(social), ...setup }, steps, overrides)
}
async function openEditSlug(page: Page) {
await openSeoPanel(page)
await press(page, 'Edit slug')
await page.getByRole('dialog').last().waitFor()
await settle(page)
}
export const metadataApiStates: Record<string, VisualState> = {
'loc.seo.noFallback': seoState({ suggestion: false }, PATTERN, 'fr_FR', true),
'loc.slug.published': seoState(
{ suggestion: false, online: { fr: true } },
PATTERN,
'fr_FR',
true,
openEditSlug,
),
'loc.url.unset': seoState({ suggestion: false }, null, 'fr_FR', true),
'loc.l10.noSocial': seoState({ suggestion: false }, PATTERN, '', false),
'loc.l10.api': {
path: '/integration',
api: {
'GET /v1/auth/me': {
status: 200,
body: {
id: 7,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
access: accessFor('owner'),
},
},
'GET /v1/image-variants': fails(500),
},
steps: async (page: Page) => {
await page.locator('[data-testid="integration-languages"]').scrollIntoViewIfNeeded()
await settle(page)
},
},
}
+1 -1
View File
@@ -20,7 +20,7 @@ export interface LocalizationScreen {
const BOARD_LINE = /^Board:\s*([^.]+?)\./m
const GROUP_HEADING = /^## (L\d+[a-z]*) (.+)$/
const STATE_ROW = /^\| (loc\.[A-Za-z0-9.]+) \| /
const SELECTOR = /LocaleFrame\{s:\{v:"([^"]+)",x:"([^"]+)"\}\}/
const SELECTOR = /LocaleFrame\{s:\{v:"([^"]+)",x:"([^"]*)"\}\}/
export function parseSizes(cell: string) {
return cell
+51
View File
@@ -0,0 +1,51 @@
import type { Page } from 'playwright'
import { openOverflow, settle, state, type Setup } from './editorLocalizationStates'
import { seoState } from './localizationMetadataStates'
import type { VisualState } from './states'
async function openDetails(page: Page) {
const width = page.viewportSize()?.width ?? 0
if (width < 1024) {
await page
.getByRole('button', { name: /^Details/ })
.first()
.click()
await page.getByRole('dialog').waitFor()
}
await page
.getByText('Public API URL', { exact: true })
.locator('visible=true')
.first()
.waitFor()
await settle(page)
}
async function openMenu(page: Page) {
await openOverflow(page)
}
async function copyApiUrl(page: Page) {
await page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: async () => undefined },
configurable: true,
})
})
await openDetails(page)
await page.getByRole('button', { name: 'Copy the public API URL' }).first().click()
await page.getByText('API URL copied', { exact: true }).waitFor()
await settle(page)
}
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
const french: Setup = { suggestion: false }
export const surfacesStates: Record<string, VisualState> = {
'loc.l12.home': { path: '/home', skip: 'clock-sensitive' },
'loc.l12.homeFr': { path: '/home', skip: 'clock-sensitive' },
'loc.l12.activity': { path: '/activity', skip: 'clock-sensitive' },
'loc.l12.menu': state(french, openMenu),
'loc.l12.apiCopy': state(french, copyApiUrl),
'loc.l12.seoPreview': seoState(french, PATTERN, 'fr_FR', true),
'loc.l12.siteFallback': seoState(french, PATTERN, 'fr_FR', true),
}
+50
View File
@@ -5,6 +5,8 @@ import { approvedExceptions } from './approvedExceptions'
import { articleListStates } from './articleListStates'
import { conflictsMovesStates, editorLocalizationStates } from './editorLocalizationStates'
import { localizationStates } from './localizationStates'
import { metadataApiStates } from './localizationMetadataStates'
import { surfacesStates } from './localizationSurfacesStates'
import { newArticleLocalizationStates } from './newArticleLocalizationStates'
import { rolesStates } from './rolesStates'
@@ -1842,6 +1844,52 @@ function mediaUsageApi(overrides: Record<string, ApiMock> = {}) {
})
}
const mediaLocalizedUsage = [
{
type: 'article',
id: 10,
title: 'How we price a bathroom renovation',
published: true,
fields: ['cover'],
story_id: 5,
lang: 'en',
},
{
type: 'article',
id: 11,
title: 'So kalkulieren wir eine Badsanierung',
published: true,
fields: ['cover'],
story_id: 5,
lang: 'de',
},
{
type: 'article',
id: 12,
title: 'Comment nous chiffrons une rénovation de salle de bain',
published: false,
fields: ['body', 'cover'],
story_id: 5,
lang: 'fr',
},
{
type: 'article',
id: 20,
title: 'A guide to lime plaster',
published: true,
fields: ['body'],
story_id: 6,
lang: 'en',
},
]
const mediaSurfaceStates: Record<string, VisualState> = {
'loc.l12.media': mediaState(
mediaApi({ 'GET /v1/media/1/usage': { status: 200, body: mediaLocalizedUsage } }),
(page) => openMediaPanel(page, 1),
),
}
const mediaDuplicateIds = [12, 5, 6]
const mediaDuplicateGroups = [mediaDuplicateIds, [7, 9]]
@@ -10352,4 +10400,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'localization-editor': editorLocalizationStates,
'localization-conflicts-moves': conflictsMovesStates,
'localization-new-article': newArticleLocalizationStates,
'localization-metadata-api': metadataApiStates,
'localization-surfaces': { ...surfacesStates, ...mediaSurfaceStates },
}