feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

+6 -6
View File
@@ -65,10 +65,10 @@ describe('index', () => {
])
})
it('holds 85 endpoints: 42 documented, 37 session only and 6 public auth', () => {
expect(index.endpoints).toHaveLength(85)
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(42)
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(37)
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)
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(40)
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
})
@@ -179,7 +179,7 @@ describe('matching', () => {
expect(routes(login.sections)).toEqual(['section Session only'])
expect(login.sections[0]!.entry).toMatchObject({
locked: true,
description: '37 routes, not available to API keys',
description: '40 routes, not available to API keys',
})
})
@@ -202,7 +202,7 @@ describe('matching', () => {
describe('custom content types', () => {
it.each([
['media', 16, 1],
['404', 23, 1],
['404', 24, 1],
['login', 1, 1],
['entry', 7, 1],
['capacity', 2, 0],
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
checkPublicUrlPattern,
previewPublicUrl,
unknownPlaceholderOf,
} from '@/lib/publicUrlPattern'
describe('Public URL pattern checks', () => {
it.each([
['https://journal.example.com/{lang}/{slug}', null],
['https://journal.example.com/{lang}/articles/{slug}', null],
['http://localhost:3000/{slug}', null],
['https://journal.example.com/{slug}/{slug}', null],
['https://journal.example.com/a%20b/{slug}', null],
[' https://journal.example.com/{slug} ', null],
['', null],
['ftp://journal.example.com/{lang}/{slug}', 'invalid_scheme'],
['journal.example.com/{slug}', 'invalid_scheme'],
['https://', 'invalid_host'],
['https:///{slug}', 'invalid_host'],
['https://journal.example.com/{language}/{slug}', 'unknown_placeholder'],
['https://journal.example.com/{lang', 'unknown_placeholder'],
['https://journal.example.com/lang}/{slug}', 'unknown_placeholder'],
['https://journal.example.com/my pages/{lang}/{slug}', 'not_encoded'],
['https://journal.example.com/é/{slug}', 'not_encoded'],
['https://journal.example.com/100%/{slug}', 'not_encoded'],
['https://journal.example.com/{lang}', 'missing_slug'],
['ftp://x.example.com/{language} here', 'invalid_scheme'],
['https://x.example.com/{language} here', 'unknown_placeholder'],
['https://x.example.com/a b', 'not_encoded'],
])('checks %j as %s', (value, expected) => {
expect(checkPublicUrlPattern(value)).toBe(expected)
})
it('names the first placeholder that is not known', () => {
expect(unknownPlaceholderOf('https://x.example.com/{language}/{slug}')).toBe('{language}')
expect(unknownPlaceholderOf('https://x.example.com/{lang')).toBe('{lang')
expect(unknownPlaceholderOf('https://x.example.com/{lang}/{slug}')).toBeNull()
})
})
describe('Public URL preview', () => {
it('replaces every placeholder', () => {
expect(
previewPublicUrl('https://journal.example.com/{lang}/{slug}/{slug}', 'fr', 'guide'),
).toBe('https://journal.example.com/fr/guide/guide')
})
})
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import {
effectiveSocialLocale,
isSocialLocaleInput,
languageName,
derivedSocialLocale,
languagesUpdate,
socialLocaleExample,
withDefault,
withEnabled,
withLanguage,
withoutLanguage,
withSocialLocale,
type SiteLanguages,
} from '@/lib/siteLanguages'
function journal(): SiteLanguages {
const row = (lang: string, enabled: boolean, extra = {}) => ({
lang,
enabled,
is_default: lang === 'en',
version_count: 1,
published_count: 1,
social_locale: null,
social_locale_state: 'not_set' as const,
...extra,
})
return {
revision: 'r1',
default: 'en',
needs_setup: false,
languages: [
row('en', true),
row('fr', true, { social_locale: 'fr_FR', social_locale_state: 'set' }),
row('es', false),
],
available: [{ lang: 'pt-BR', substitute: null }],
}
}
describe('Language names', () => {
it.each([
['en', 'en', 'English'],
['pt-BR', 'en', 'Portuguese (Brazil)'],
['en-GB', 'en', 'English (United Kingdom)'],
['pt-PT', 'en', 'Portuguese (Portugal)'],
['fr', 'fr', 'Français'],
])('names %s in %s as %s', (tag, locale, expected) => {
expect(languageName(tag, locale as 'en' | 'fr')).toBe(expected)
})
it('falls back to the tag when it has no name', () => {
expect(languageName('not a tag', 'en')).toBe('not a tag')
})
})
describe('Social locale', () => {
it('is explicit when set, derived from a regional tag, and absent otherwise', () => {
const rows = journal().languages
expect(effectiveSocialLocale(rows[1]!)).toBe('fr_FR')
expect(effectiveSocialLocale(rows[0]!)).toBeNull()
expect(
effectiveSocialLocale({
...rows[0]!,
lang: 'pt-BR',
social_locale_state: 'derived',
}),
).toBe('pt_BR')
})
it.each([
['fr_FR', 'fr', true],
['fr_CA', 'fr', true],
['fil_PH', 'fil', true],
['fr-FR', 'fr', false],
['fr', 'fr', false],
['', 'fr', false],
['pt_BR', 'fr', false],
['fr_', 'fr', false],
['fr_FRA', 'fr', false],
])('checks %j on %s as %s', (value, tag, expected) => {
expect(isSocialLocaleInput(value, tag)).toBe(expected)
})
})
describe('Building the full state to send', () => {
it('sends every row with the revision and the default', () => {
expect(languagesUpdate(journal())).toEqual({
revision: 'r1',
default: 'en',
languages: [
{ lang: 'en', enabled: true, social_locale: null },
{ lang: 'fr', enabled: true, social_locale: 'fr_FR' },
{ lang: 'es', enabled: false, social_locale: null },
],
})
})
it('adds a language as an enabled row', () => {
const body = languagesUpdate(withLanguage(journal(), 'pt-BR'))
expect(body.languages.at(-1)).toEqual({
lang: 'pt-BR',
enabled: true,
social_locale: null,
})
})
it('changes the default without touching enabled flags', () => {
const body = languagesUpdate(withDefault(journal(), 'fr'))
expect(body.default).toBe('fr')
expect(body.languages.map((row) => row.enabled)).toEqual([true, true, false])
})
it('disables and re-enables one language', () => {
const off = languagesUpdate(withEnabled(journal(), 'fr', false))
expect(off.languages[1]!.enabled).toBe(false)
const on = languagesUpdate(withEnabled(journal(), 'es', true))
expect(on.languages[2]!.enabled).toBe(true)
})
it('removes a language', () => {
const body = languagesUpdate(withoutLanguage(journal(), 'es'))
expect(body.languages.map((row) => row.lang)).toEqual(['en', 'fr'])
})
it('sets and clears a social locale', () => {
expect(
languagesUpdate(withSocialLocale(journal(), 'fr', 'fr_CA')).languages[1]!.social_locale,
).toBe('fr_CA')
expect(
languagesUpdate(withSocialLocale(journal(), 'fr', null)).languages[1]!.social_locale,
).toBeNull()
})
})
describe('Social locale examples', () => {
it.each([
['fr', 'fr_FR'],
['en', 'en_US'],
['pt-BR', 'pt_BR'],
['en-GB', 'en_GB'],
['zh-Hans', 'zh_CN'],
['de', 'de_DE'],
])('suggests %s as %s', (tag, expected) => {
expect(socialLocaleExample(tag)).toBe(expected)
})
it('derives a value only from a tag with a region', () => {
expect(derivedSocialLocale('pt-BR')).toBe('pt_BR')
expect(derivedSocialLocale('fr')).toBeNull()
expect(derivedSocialLocale('zh-Hans')).toBeNull()
})
})
+108
View File
@@ -0,0 +1,108 @@
import { t } from '@/i18n'
import { effectiveSocialLocale, type SiteLanguage } from '@/lib/siteLanguages'
export function versionsLine(language: SiteLanguage) {
return language.version_count === 1
? t('1 version · {0} published', [language.published_count])
: t('{0} versions · {1} published', [language.version_count, language.published_count])
}
export function socialLocaleLabel(language: SiteLanguage) {
const value = effectiveSocialLocale(language)
if (language.social_locale_state === 'set') {
return t('Set: {0}', [value ?? ''])
}
if (language.social_locale_state === 'derived') {
return t('Derived: {0}', [value ?? ''])
}
return t('Not set')
}
function draftsOf(language: SiteLanguage) {
return language.version_count - language.published_count
}
function usageParts(language: SiteLanguage) {
const drafts = draftsOf(language)
const parts: string[] = []
if (drafts > 0) {
parts.push(drafts === 1 ? t('1 draft') : t('{0} drafts', [drafts]))
}
if (language.published_count > 0) {
parts.push(t('{0} published', [language.published_count]))
}
return parts.join(t(' and '))
}
export function disableDescription(name: string, language: SiteLanguage) {
if (language.version_count === 0) {
return t('No article uses {0}.', [name])
}
const count =
language.version_count === 1
? t('{0} is used by 1 version: {1}.', [name, usageParts(language)])
: t('{0} is used by {1} versions: {2}.', [
name,
language.version_count,
usageParts(language),
])
return count
}
export function disableWarning(name: string, language: SiteLanguage) {
const drafts = draftsOf(language)
const sentences: string[] = []
if (language.published_count === 1) {
sentences.push(t('The published {0} version stays online.', [name]))
} else if (language.published_count > 1) {
sentences.push(
t('The {0} published {1} versions stay online.', [language.published_count, name]),
)
}
sentences.push(
language.version_count === 1
? t('The version stays readable and editable.')
: t('All {0} versions stay readable and editable.', [language.version_count]),
)
if (drafts === 1) {
sentences.push(t("The draft can't be published until you re-enable {0}.", [name]))
} else if (drafts > 1) {
sentences.push(
t("The {0} drafts can't be published until you re-enable {1}.", [drafts, name]),
)
}
sentences.push(t('Nobody can create new {0} versions.', [name]))
return sentences.join(' ')
}
export function reenableDescription(name: string, language: SiteLanguage) {
if (language.version_count === 0) {
return t('Writers can create {0} versions again. Nothing else changes.', [name])
}
return language.version_count === 1
? t(
'Writers can create {0} versions again. The 1 existing {0} version stays as it is. Nothing else changes.',
[name],
)
: t(
'Writers can create {0} versions again. The {1} existing {0} versions stay as they are. Nothing else changes.',
[name, language.version_count],
)
}
export function removeBlockedDescription(name: string, language: SiteLanguage) {
return language.version_count === 1
? t('{0} is used by 1 version. Disable the language to keep it, or delete it first.', [
name,
])
: t('{0} is used by {1} versions. Disable the language to keep it, or delete it first.', [
name,
language.version_count,
])
}
export function usedByLine(name: string, language: SiteLanguage) {
return language.version_count === 1
? t('{0} is used by 1 version.', [name])
: t('{0} is used by {1} versions.', [name, language.version_count])
}
+47
View File
@@ -0,0 +1,47 @@
export type PublicUrlPatternError =
| 'invalid_scheme'
| 'invalid_host'
| 'unknown_placeholder'
| 'not_encoded'
| 'missing_slug'
export const PUBLIC_URL_PATTERN_MAX_LENGTH = 512
const HTTP_SCHEME = /^https?:\/\//i
const SCHEME_WITH_HOST = /^https?:\/\/[^/?#\s]+/i
const BRACE_GROUP = /\{[^{}]*\}?|\}/g
const KNOWN_PLACEHOLDERS = new Set(['{lang}', '{slug}'])
const ENCODED_CHARACTERS = /^(?:[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]|%[0-9A-Fa-f]{2})*$/
export function unknownPlaceholderOf(pattern: string) {
const found = pattern.match(BRACE_GROUP) ?? []
return found.find((token) => !KNOWN_PLACEHOLDERS.has(token)) ?? null
}
export function checkPublicUrlPattern(value: string): PublicUrlPatternError | null {
const pattern = value.trim()
if (pattern === '') {
return null
}
if (!HTTP_SCHEME.test(pattern)) {
return 'invalid_scheme'
}
if (!SCHEME_WITH_HOST.test(pattern)) {
return 'invalid_host'
}
if (unknownPlaceholderOf(pattern) !== null) {
return 'unknown_placeholder'
}
const literal = pattern.replace(/\{lang\}|\{slug\}/g, '')
if (!ENCODED_CHARACTERS.test(literal)) {
return 'not_encoded'
}
return pattern.includes('{slug}') ? null : 'missing_slug'
}
export function previewPublicUrl(pattern: string, lang: string, slug: string) {
return pattern
.trim()
.replace(/\{lang\}/g, lang)
.replace(/\{slug\}/g, slug)
}
+153
View File
@@ -0,0 +1,153 @@
export type SocialLocaleState = 'set' | 'derived' | 'not_set'
export interface SiteLanguage {
lang: string
enabled: boolean
is_default: boolean
version_count: number
published_count: number
social_locale: string | null
social_locale_state: SocialLocaleState
}
export interface AvailableLanguage {
lang: string
substitute: string | null
}
export interface SiteLanguages {
revision: string
default: string | null
needs_setup: boolean
languages: SiteLanguage[]
available: AvailableLanguage[]
}
export interface LanguageEntry {
lang: string
enabled: boolean
social_locale: string | null
}
export interface LanguagesUpdate {
revision: string
default: string
languages: LanguageEntry[]
}
export interface EditedLanguages {
revision: string
default: string
entries: LanguageEntry[]
}
const SOCIAL_LOCALE_PATTERN = /^[A-Za-z]{2,3}_[A-Za-z]{2}$/
export function languageName(tag: string, locale: 'en' | 'fr') {
let name = tag
try {
name =
new Intl.DisplayNames([locale], { type: 'language', languageDisplay: 'standard' }).of(
tag,
) ?? tag
} catch {
return tag
}
return name.charAt(0).toLocaleUpperCase(locale) + name.slice(1)
}
export function baseLanguage(tag: string) {
return tag.split('-')[0]!.toLowerCase()
}
export function effectiveSocialLocale(language: SiteLanguage) {
if (language.social_locale_state === 'set') {
return language.social_locale
}
if (language.social_locale_state === 'derived') {
return language.lang.replace('-', '_')
}
return null
}
export function isSocialLocaleInput(value: string, tag: string) {
return (
SOCIAL_LOCALE_PATTERN.test(value) &&
baseLanguage(value.split('_')[0]!) === baseLanguage(tag)
)
}
export function editedLanguages(state: SiteLanguages): EditedLanguages {
return {
revision: state.revision,
default: state.default ?? '',
entries: state.languages.map((row) => ({
lang: row.lang,
enabled: row.enabled,
social_locale: row.social_locale,
})),
}
}
function mapEntry(
state: SiteLanguages,
tag: string,
change: (entry: LanguageEntry) => LanguageEntry,
): EditedLanguages {
const edited = editedLanguages(state)
return {
...edited,
entries: edited.entries.map((entry) => (entry.lang === tag ? change(entry) : entry)),
}
}
export function withLanguage(state: SiteLanguages, tag: string): EditedLanguages {
const edited = editedLanguages(state)
return {
...edited,
entries: [...edited.entries, { lang: tag, enabled: true, social_locale: null }],
}
}
export function withDefault(state: SiteLanguages, tag: string): EditedLanguages {
return { ...editedLanguages(state), default: tag }
}
export function withEnabled(state: SiteLanguages, tag: string, enabled: boolean) {
return mapEntry(state, tag, (entry) => ({ ...entry, enabled }))
}
export function withoutLanguage(state: SiteLanguages, tag: string): EditedLanguages {
const edited = editedLanguages(state)
return { ...edited, entries: edited.entries.filter((entry) => entry.lang !== tag) }
}
export function withSocialLocale(state: SiteLanguages, tag: string, value: string | null) {
return mapEntry(state, tag, (entry) => ({ ...entry, social_locale: value }))
}
export function languagesUpdate(change: SiteLanguages | EditedLanguages): LanguagesUpdate {
const edited = 'entries' in change ? change : editedLanguages(change)
return { revision: edited.revision, default: edited.default, languages: edited.entries }
}
const EXAMPLE_REGIONS: Record<string, string> = { en: 'US', ar: 'EG', ja: 'JP', zh: 'CN', pt: 'PT' }
export function regionOf(tag: string) {
return tag
.split('-')
.slice(1)
.find((part) => /^[A-Za-z]{2}$/.test(part))
?.toUpperCase()
}
export function socialLocaleExample(tag: string) {
const base = baseLanguage(tag)
const region = regionOf(tag) ?? EXAMPLE_REGIONS[base] ?? base.toUpperCase()
return `${base}_${region}`
}
export function derivedSocialLocale(tag: string) {
const region = regionOf(tag)
return region ? `${baseLanguage(tag)}_${region}` : null
}