feat: site languages, setup and site URL settings
This commit is contained in:
1 parent
0806733d5c
commit
d9158025d4
106 files changed
+10184
-56
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user