@plutocms/pluto 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +2 -0
- package/CHANGELOG.md +15 -0
- package/FEATURES.md +7 -0
- package/app/app.vue +5 -2
- package/app/components/ColorModeButton.vue +2 -1
- package/app/components/Pluto/Content/PlutoContentField.vue +17 -3
- package/app/components/Pluto/Content/PlutoContentForm.vue +60 -38
- package/app/components/Pluto/Content/PlutoContentList.vue +38 -32
- package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +2 -1
- package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +22 -19
- package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +15 -14
- package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +6 -5
- package/app/components/Pluto/PlutoDashboardWidgets.vue +2 -1
- package/app/components/Pluto/PlutoRoot.vue +18 -1
- package/app/components/Pluto/Settings/PlutoSettingsLanguage.vue +51 -0
- package/app/components/modal/ModalHeader.vue +3 -1
- package/app/composables/internal/content-text.ts +111 -0
- package/app/composables/internal/error-text.ts +62 -0
- package/app/composables/internal/i18n-core.ts +310 -0
- package/app/composables/internal/i18n-runtime.ts +87 -0
- package/app/composables/pluto-admin-nav.ts +21 -4
- package/app/composables/pluto-admin-pages.ts +13 -3
- package/app/composables/pluto-command-palette.ts +13 -6
- package/app/composables/pluto-content-actions.ts +9 -5
- package/app/composables/pluto-content-text.ts +28 -0
- package/app/composables/pluto-dashboard.ts +15 -2
- package/app/composables/pluto-error.ts +13 -0
- package/app/composables/pluto-extension.ts +4 -0
- package/app/composables/pluto-i18n.ts +171 -0
- package/app/composables/pluto-permissions.ts +16 -2
- package/app/composables/pluto-registry.ts +1 -0
- package/app/composables/pluto-settings.ts +16 -2
- package/app/layouts/admin.vue +2 -1
- package/app/pages/admin/content/[type]/[id].vue +3 -1
- package/app/pages/admin/content/[type]/index.vue +2 -1
- package/app/pages/admin/content/[type]/new.vue +2 -1
- package/app/pages/admin/home.vue +6 -4
- package/app/pages/admin/settings.vue +10 -5
- package/app/plugins/pluto-core-registrations.ts +10 -7
- package/app/plugins/pluto-i18n.ts +20 -0
- package/i18n/locales/en.json +137 -0
- package/i18n/locales/pt-BR.json +137 -0
- package/modules/pluto-i18n/index.ts +89 -0
- package/modules/pluto-i18n/locale-files.ts +187 -0
- package/package.json +2 -2
- package/server/utils/pluto-content-handlers.ts +30 -7
- package/server/utils/pluto-error.ts +27 -0
- package/shared/types/i18n.d.ts +10 -0
- package/shared/types/i18n.ts +14 -0
- package/shared/types/registry.ts +13 -0
- package/shared/utils/content-validate.ts +17 -7
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
const WEEKS = 12
|
|
8
8
|
|
|
9
9
|
const { data: series, status, refresh } = usePlutoContentActivity(WEEKS)
|
|
10
|
+
const { t, n, d } = usePlutoI18n()
|
|
11
|
+
const texts = usePlutoContentTexts()
|
|
10
12
|
|
|
11
|
-
const number = new Intl.NumberFormat()
|
|
12
13
|
// The week starts are UTC dates. Format them in UTC so a viewer west of
|
|
13
14
|
// UTC does not see the previous day.
|
|
14
|
-
const weekLabel = new
|
|
15
|
+
const weekLabel = (start: string) => d(new Date(`${start}T00:00:00Z`), { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
15
16
|
|
|
16
17
|
const rows = computed(() => {
|
|
17
18
|
const first = series.value[0]
|
|
@@ -19,7 +20,7 @@ const rows = computed(() => {
|
|
|
19
20
|
return []
|
|
20
21
|
}
|
|
21
22
|
return first.points.map((point, index) => {
|
|
22
|
-
const row: Record<string, string | number> = { week: weekLabel
|
|
23
|
+
const row: Record<string, string | number> = { week: weekLabel(point.start) }
|
|
23
24
|
for (const entry of series.value) {
|
|
24
25
|
row[entry.name] = entry.points[index]?.count ?? 0
|
|
25
26
|
}
|
|
@@ -27,7 +28,9 @@ const rows = computed(() => {
|
|
|
27
28
|
})
|
|
28
29
|
})
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
// The series carry the English labels. The content text resolves the keys.
|
|
32
|
+
const seriesLabel = (entry: PlutoContentActivitySeries) => texts(entry.name).labelPlural
|
|
33
|
+
const categories = computed(() => series.value.map((entry) => ({ key: entry.name, label: seriesLabel(entry) })))
|
|
31
34
|
|
|
32
35
|
const total = computed(() =>
|
|
33
36
|
series.value.reduce((sum, entry) => sum + entry.points.reduce((acc, point) => acc + point.count, 0), 0)
|
|
@@ -45,12 +48,12 @@ function retry() {
|
|
|
45
48
|
<template #header>
|
|
46
49
|
<div class="flex items-start justify-between gap-4">
|
|
47
50
|
<div>
|
|
48
|
-
<h3 class="text-highlighted font-semibold">
|
|
49
|
-
<p class="text-muted text-sm">
|
|
51
|
+
<h3 class="text-highlighted font-semibold">{{ t('pluto.dashboard.activity.title') }}</h3>
|
|
52
|
+
<p class="text-muted text-sm">{{ t('pluto.dashboard.activity.subtitle', { weeks: WEEKS }) }}</p>
|
|
50
53
|
</div>
|
|
51
54
|
<div v-if="status === 'success' && series.length" class="text-right">
|
|
52
|
-
<p class="text-highlighted text-2xl font-semibold tabular-nums">{{
|
|
53
|
-
<p class="text-muted text-xs">created</p>
|
|
55
|
+
<p class="text-highlighted text-2xl font-semibold tabular-nums">{{ n(total) }}</p>
|
|
56
|
+
<p class="text-muted text-xs">{{ t('pluto.dashboard.activity.created', total) }}</p>
|
|
54
57
|
</div>
|
|
55
58
|
</div>
|
|
56
59
|
</template>
|
|
@@ -59,26 +62,26 @@ function retry() {
|
|
|
59
62
|
|
|
60
63
|
<UAlert
|
|
61
64
|
v-else-if="status === 'error'"
|
|
62
|
-
:actions="[{ label: '
|
|
65
|
+
:actions="[{ label: t('pluto.common.tryAgain'), color: 'error', variant: 'outline', onClick: retry }]"
|
|
66
|
+
:title="t('pluto.dashboard.activity.loadFailed')"
|
|
63
67
|
color="error"
|
|
64
68
|
variant="subtle"
|
|
65
69
|
icon="lucide:circle-alert"
|
|
66
|
-
title="Could not load the activity"
|
|
67
70
|
/>
|
|
68
71
|
|
|
69
72
|
<UEmpty
|
|
70
73
|
v-else-if="!series.length"
|
|
74
|
+
:title="t('pluto.dashboard.activity.unavailable')"
|
|
75
|
+
:description="t('pluto.dashboard.activity.unavailableHint')"
|
|
71
76
|
icon="lucide:chart-line"
|
|
72
|
-
title="Activity is not available"
|
|
73
|
-
description="The content backend cannot count items by creation date yet."
|
|
74
77
|
variant="naked"
|
|
75
78
|
/>
|
|
76
79
|
|
|
77
80
|
<UEmpty
|
|
78
81
|
v-else-if="total === 0"
|
|
79
|
-
:title="
|
|
82
|
+
:title="t('pluto.dashboard.activity.none', { weeks: WEEKS })"
|
|
83
|
+
:description="t('pluto.dashboard.activity.noneHint')"
|
|
80
84
|
icon="lucide:chart-line"
|
|
81
|
-
description="New items show up here week by week."
|
|
82
85
|
variant="naked"
|
|
83
86
|
/>
|
|
84
87
|
|
|
@@ -88,18 +91,18 @@ function retry() {
|
|
|
88
91
|
:data="rows"
|
|
89
92
|
:categories="categories"
|
|
90
93
|
:height="256"
|
|
91
|
-
:format="(value: number) =>
|
|
92
|
-
:aria-label="
|
|
94
|
+
:format="(value: number) => n(value)"
|
|
95
|
+
:aria-label="t('pluto.dashboard.activity.subtitle', { weeks: WEEKS })"
|
|
93
96
|
index="week"
|
|
94
97
|
curve="monotone"
|
|
95
98
|
/>
|
|
96
99
|
|
|
97
100
|
<table class="sr-only">
|
|
98
|
-
<caption>
|
|
101
|
+
<caption>{{ t('pluto.dashboard.activity.subtitle', { weeks: WEEKS }) }}</caption>
|
|
99
102
|
<thead>
|
|
100
103
|
<tr>
|
|
101
|
-
<th scope="col">
|
|
102
|
-
<th v-for="entry in series" :key="entry.name" scope="col">{{ entry
|
|
104
|
+
<th scope="col">{{ t('pluto.dashboard.activity.weekOf') }}</th>
|
|
105
|
+
<th v-for="entry in series" :key="entry.name" scope="col">{{ seriesLabel(entry) }}</th>
|
|
103
106
|
</tr>
|
|
104
107
|
</thead>
|
|
105
108
|
<tbody>
|
|
@@ -9,7 +9,8 @@ const { items: contentTypes } = usePlutoContentTypes()
|
|
|
9
9
|
const { stats, pending, error, refresh } = usePlutoContentStats()
|
|
10
10
|
const { data: activity } = usePlutoContentActivity()
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const { t, n } = usePlutoI18n()
|
|
13
|
+
const texts = usePlutoContentTexts()
|
|
13
14
|
|
|
14
15
|
function retry() {
|
|
15
16
|
refresh().catch(() => {})
|
|
@@ -26,14 +27,14 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
|
|
|
26
27
|
</script>
|
|
27
28
|
|
|
28
29
|
<template>
|
|
29
|
-
<section v-if="contentTypes.length" aria-label="
|
|
30
|
+
<section v-if="contentTypes.length" :aria-label="t('pluto.dashboard.overview.label')">
|
|
30
31
|
<UAlert
|
|
31
32
|
v-if="error && !stats.length"
|
|
32
|
-
:actions="[{ label: '
|
|
33
|
+
:actions="[{ label: t('pluto.common.tryAgain'), color: 'error', variant: 'outline', onClick: retry }]"
|
|
34
|
+
:title="t('pluto.dashboard.overview.loadFailed')"
|
|
33
35
|
color="error"
|
|
34
36
|
variant="subtle"
|
|
35
37
|
icon="lucide:circle-alert"
|
|
36
|
-
title="Could not load the content counts"
|
|
37
38
|
/>
|
|
38
39
|
|
|
39
40
|
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
@@ -57,7 +58,7 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
|
|
|
57
58
|
<UCard class="group-hover:ring-accented h-full transition-shadow group-hover:shadow-sm">
|
|
58
59
|
<div class="flex flex-col gap-3">
|
|
59
60
|
<div class="flex items-start justify-between gap-3">
|
|
60
|
-
<p class="text-muted text-sm font-medium">{{ stat.labelPlural }}</p>
|
|
61
|
+
<p class="text-muted text-sm font-medium">{{ texts(stat.name).labelPlural }}</p>
|
|
61
62
|
<span class="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md">
|
|
62
63
|
<UIcon :name="stat.icon ?? 'lucide:file'" class="size-4" />
|
|
63
64
|
</span>
|
|
@@ -65,14 +66,14 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
|
|
|
65
66
|
|
|
66
67
|
<div class="flex items-baseline gap-2">
|
|
67
68
|
<p class="text-highlighted text-3xl font-semibold tracking-tight tabular-nums">
|
|
68
|
-
{{
|
|
69
|
+
{{ n(stat.total) }}
|
|
69
70
|
</p>
|
|
70
71
|
<p v-if="thisWeek(stat) !== undefined" class="text-sm">
|
|
71
72
|
<template v-if="thisWeek(stat)">
|
|
72
|
-
<span class="text-success font-medium tabular-nums">+{{
|
|
73
|
-
<span class="text-muted">
|
|
73
|
+
<span class="text-success font-medium tabular-nums">+{{ n(thisWeek(stat)!) }}</span>
|
|
74
|
+
<span class="text-muted"> {{ t('pluto.dashboard.overview.thisWeek') }}</span>
|
|
74
75
|
</template>
|
|
75
|
-
<span v-else class="text-dimmed">
|
|
76
|
+
<span v-else class="text-dimmed">{{ t('pluto.dashboard.overview.noneThisWeek') }}</span>
|
|
76
77
|
</p>
|
|
77
78
|
</div>
|
|
78
79
|
|
|
@@ -80,7 +81,7 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
|
|
|
80
81
|
<div
|
|
81
82
|
:aria-valuenow="stat.published"
|
|
82
83
|
:aria-valuemax="stat.total"
|
|
83
|
-
:aria-label="
|
|
84
|
+
:aria-label="t('pluto.dashboard.overview.meter', { published: stat.published, total: stat.total, labelPlural: texts(stat.name).labelPluralLower })"
|
|
84
85
|
role="meter"
|
|
85
86
|
aria-valuemin="0"
|
|
86
87
|
class="bg-accented flex h-1.5 overflow-hidden rounded-full"
|
|
@@ -89,10 +90,10 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
|
|
|
89
90
|
</div>
|
|
90
91
|
|
|
91
92
|
<p class="text-muted text-sm">
|
|
92
|
-
<span class="text-highlighted font-medium tabular-nums">{{
|
|
93
|
-
published ·
|
|
94
|
-
<span class="tabular-nums">{{
|
|
95
|
-
{{ stat.drafts
|
|
93
|
+
<span class="text-highlighted font-medium tabular-nums">{{ n(stat.published) }}</span>
|
|
94
|
+
{{ t('pluto.dashboard.overview.published', stat.published) }} ·
|
|
95
|
+
<span class="tabular-nums">{{ n(stat.drafts ?? 0) }}</span>
|
|
96
|
+
{{ t('pluto.dashboard.overview.drafts', stat.drafts ?? 0) }}
|
|
96
97
|
</p>
|
|
97
98
|
</template>
|
|
98
99
|
</div>
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
// the two never disagree: every "New <label>" command the user can run, and
|
|
4
4
|
// a few admin destinations.
|
|
5
5
|
const groups = usePlutoCommandPalette()
|
|
6
|
+
const { t } = usePlutoI18n()
|
|
6
7
|
|
|
7
8
|
const create = computed(() => groups.value.find((group) => group.id === 'pluto-create')?.items ?? [])
|
|
8
9
|
|
|
9
10
|
const goTo = computed(() =>
|
|
10
11
|
(groups.value.find((group) => group.id === 'pluto-go-to')?.items ?? [])
|
|
11
12
|
// Home is the page this widget is on.
|
|
12
|
-
.filter((item) => item.label !== '
|
|
13
|
+
.filter((item) => item.label !== t('pluto.nav.home'))
|
|
13
14
|
.slice(0, 4)
|
|
14
15
|
)
|
|
15
16
|
|
|
@@ -23,12 +24,12 @@ function run(item: {
|
|
|
23
24
|
<template>
|
|
24
25
|
<UCard class="h-full">
|
|
25
26
|
<template #header>
|
|
26
|
-
<h3 class="text-highlighted font-semibold">
|
|
27
|
+
<h3 class="text-highlighted font-semibold">{{ t('pluto.dashboard.quickActions.title') }}</h3>
|
|
27
28
|
<p class="text-muted text-sm">
|
|
28
|
-
|
|
29
|
+
{{ t('pluto.dashboard.quickActions.pressBefore') }}
|
|
29
30
|
<UKbd value="meta" size="sm" />
|
|
30
31
|
<UKbd value="k" size="sm" />
|
|
31
|
-
|
|
32
|
+
{{ t('pluto.dashboard.quickActions.pressAfter') }}
|
|
32
33
|
</p>
|
|
33
34
|
</template>
|
|
34
35
|
|
|
@@ -49,7 +50,7 @@ function run(item: {
|
|
|
49
50
|
</div>
|
|
50
51
|
|
|
51
52
|
<div v-if="goTo.length" class="flex flex-col gap-1">
|
|
52
|
-
<p class="text-dimmed text-xs font-medium tracking-wide uppercase">
|
|
53
|
+
<p class="text-dimmed text-xs font-medium tracking-wide uppercase">{{ t('pluto.palette.goTo') }}</p>
|
|
53
54
|
<UButton
|
|
54
55
|
v-for="item in goTo"
|
|
55
56
|
:key="item.label"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const { items } = usePlutoDashboardWidgets()
|
|
3
|
+
const { t } = usePlutoI18n()
|
|
3
4
|
</script>
|
|
4
5
|
|
|
5
6
|
<template>
|
|
@@ -15,7 +16,7 @@ const { items } = usePlutoDashboardWidgets()
|
|
|
15
16
|
|
|
16
17
|
<UCard v-else>
|
|
17
18
|
<p class="text-muted text-sm">
|
|
18
|
-
|
|
19
|
+
{{ t('pluto.dashboard.noWidgets') }}
|
|
19
20
|
<code>definePlutoExtension</code>.
|
|
20
21
|
</p>
|
|
21
22
|
</UCard>
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const { component: navbar } = usePlutoNavbarShell()
|
|
3
|
+
|
|
4
|
+
// One Nuxt UI locale per page: the admin's on /admin routes, the site's on the
|
|
5
|
+
// rest. The admin navbar on a public page keeps the site locale for Nuxt UI
|
|
6
|
+
// text, but its own labels use the admin locale through usePlutoI18n().
|
|
7
|
+
const route = useRoute()
|
|
8
|
+
const admin = usePlutoI18n()
|
|
9
|
+
const site = usePlutoSiteI18n()
|
|
10
|
+
const isAdmin = computed(() => route.path === '/admin' || route.path.startsWith('/admin/'))
|
|
11
|
+
const active = computed(() => (isAdmin.value ? admin : site))
|
|
12
|
+
const uiLocale = computed(() => active.value.uiLocale.value)
|
|
13
|
+
|
|
14
|
+
useHead({
|
|
15
|
+
htmlAttrs: {
|
|
16
|
+
lang: computed(() => active.value.locale.value),
|
|
17
|
+
dir: computed(() => uiLocale.value?.dir ?? 'ltr'),
|
|
18
|
+
},
|
|
19
|
+
})
|
|
3
20
|
</script>
|
|
4
21
|
|
|
5
22
|
<template>
|
|
6
23
|
<div class="flex min-h-0 min-w-0 w-full grow flex-col">
|
|
7
|
-
<UApp class="flex min-h-0 min-w-0 w-full grow flex-col">
|
|
24
|
+
<UApp :locale="uiLocale" class="flex min-h-0 min-w-0 w-full grow flex-col">
|
|
8
25
|
<NuxtRouteAnnouncer />
|
|
9
26
|
<NuxtLoadingIndicator />
|
|
10
27
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { getLanguageName } from '../../../composables/internal/i18n-core'
|
|
3
|
+
|
|
4
|
+
// The select cannot hold null, so Automatic uses this value. It is not a
|
|
5
|
+
// valid locale code, so it never clashes with one.
|
|
6
|
+
const AUTOMATIC = '__automatic__'
|
|
7
|
+
|
|
8
|
+
const { t, locales, preference, setLocale } = usePlutoI18n()
|
|
9
|
+
const toast = useToast()
|
|
10
|
+
|
|
11
|
+
const items = computed(() => [
|
|
12
|
+
{ label: t('pluto.settings.language.automatic'), value: AUTOMATIC },
|
|
13
|
+
// Each language is named in its own language, so a user finds it.
|
|
14
|
+
...locales.value.map((code) => ({ label: getLanguageName(code), value: code })),
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
const selected = computed(() => preference.value ?? AUTOMATIC)
|
|
18
|
+
|
|
19
|
+
async function onChange(value: string) {
|
|
20
|
+
try {
|
|
21
|
+
await setLocale(value === AUTOMATIC ? null : value)
|
|
22
|
+
toast.add({ title: t('pluto.settings.language.saved'), color: 'success' })
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// The switch stays on this device. Only the save failed.
|
|
25
|
+
toast.add({
|
|
26
|
+
title: t('pluto.settings.language.saveFailed'),
|
|
27
|
+
description: t('pluto.settings.language.saveFailedDescription', {
|
|
28
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
29
|
+
}),
|
|
30
|
+
color: 'error',
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<UCard>
|
|
38
|
+
<template #header>
|
|
39
|
+
<h2 class="font-semibold">{{ t('pluto.settings.language.cardTitle') }}</h2>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<UFormField :label="t('pluto.settings.language.label')" :description="t('pluto.settings.language.description')">
|
|
43
|
+
<USelect
|
|
44
|
+
:model-value="selected"
|
|
45
|
+
:items="items"
|
|
46
|
+
class="w-full sm:w-64"
|
|
47
|
+
@update:model-value="onChange"
|
|
48
|
+
/>
|
|
49
|
+
</UFormField>
|
|
50
|
+
</UCard>
|
|
51
|
+
</template>
|
|
@@ -16,6 +16,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
16
16
|
|
|
17
17
|
const emit = defineEmits(['close'])
|
|
18
18
|
|
|
19
|
+
const { t } = usePlutoI18n()
|
|
20
|
+
|
|
19
21
|
const closeModel = defineModel<() => void>('close')
|
|
20
22
|
|
|
21
23
|
const resolvedClasses = computed(() => {
|
|
@@ -56,7 +58,7 @@ function closeModal() {
|
|
|
56
58
|
|
|
57
59
|
<div v-if="props.hideCloseButton === false">
|
|
58
60
|
<UButton size="sm" variant="ghost" square @click="closeModal">
|
|
59
|
-
<span class="sr-only">
|
|
61
|
+
<span class="sr-only">{{ t('pluto.common.close') }}</span>
|
|
60
62
|
|
|
61
63
|
<Icon name="lucide:x" class="text-xl" />
|
|
62
64
|
</UButton>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Pure part of `usePlutoContentText`. It does not use Nuxt auto-imports, so
|
|
2
|
+
// plain Vitest can import it, and so a composable can call it for each type
|
|
3
|
+
// inside one `computed`.
|
|
4
|
+
import type { PlutoContentType, PlutoField } from '../../../shared/types/content'
|
|
5
|
+
import type { MessageParams } from './i18n-core'
|
|
6
|
+
|
|
7
|
+
/** The part of `usePlutoI18n()` that content text needs. */
|
|
8
|
+
export interface ContentTextI18n {
|
|
9
|
+
t: (key: string, params?: MessageParams | number) => string
|
|
10
|
+
te: (key: string) => boolean
|
|
11
|
+
locale: {
|
|
12
|
+
readonly value: string
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PlutoContentText {
|
|
17
|
+
/** The singular label: the key `content.<name>.label`, else `type.label`. */
|
|
18
|
+
readonly label: string
|
|
19
|
+
/** The plural label: the key `content.<name>.labelPlural`, else `type.labelPlural`. */
|
|
20
|
+
readonly labelPlural: string
|
|
21
|
+
/** The label in lowercase, for use inside a sentence. */
|
|
22
|
+
readonly labelLower: string
|
|
23
|
+
/** The plural label in lowercase, for use inside a sentence. */
|
|
24
|
+
readonly labelPluralLower: string
|
|
25
|
+
/** The "New <label>" text: the key `content.<name>.new`, else the core phrase. */
|
|
26
|
+
readonly newLabel: string
|
|
27
|
+
/** The label of a field: the key `content.<name>.fields.<field>.label`, else `field.label`. */
|
|
28
|
+
fieldLabel: (field: PlutoField) => string
|
|
29
|
+
/** A copy of a field with translated label, description, placeholder and option labels. */
|
|
30
|
+
field: <F extends PlutoField>(field: F) => F
|
|
31
|
+
/** The label of a status value: the key `content.<name>.status.<value>`, else `literal`, else the value. */
|
|
32
|
+
statusLabel: (value: string, literal?: string) => string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolves the convention keys of one content type, with the English literal
|
|
37
|
+
* of the type as the fallback. A key is used only when it exists, so a type
|
|
38
|
+
* that has no key keeps its English text.
|
|
39
|
+
*
|
|
40
|
+
* `getType` runs on each read, so a locale switch or a changed type shows at
|
|
41
|
+
* once when the caller reads inside a reactive scope.
|
|
42
|
+
*/
|
|
43
|
+
export function createContentText(getType: () => PlutoContentType | undefined, i18n: ContentTextI18n): PlutoContentText {
|
|
44
|
+
const prefix = () => `content.${getType()?.name ?? ''}`
|
|
45
|
+
|
|
46
|
+
/** The translation when the key exists, else the literal. */
|
|
47
|
+
function pick<L extends string | undefined>(key: string, literal: L): string | L {
|
|
48
|
+
return i18n.te(key) ? i18n.t(key) : literal
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lower = (value: string) => value.toLocaleLowerCase(i18n.locale.value)
|
|
52
|
+
const label = () => pick(`${prefix()}.label`, getType()?.label ?? '')
|
|
53
|
+
const labelPlural = () => pick(`${prefix()}.labelPlural`, getType()?.labelPlural ?? '')
|
|
54
|
+
|
|
55
|
+
function fieldLabel(field: PlutoField): string {
|
|
56
|
+
return pick(`${prefix()}.fields.${field.name}.label`, field.label)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function field<F extends PlutoField>(source: F): F {
|
|
60
|
+
const base = `${prefix()}.fields.${source.name}`
|
|
61
|
+
const copy: Record<string, unknown> = { ...source, label: fieldLabel(source) }
|
|
62
|
+
|
|
63
|
+
const description = pick(`${base}.description`, source.description)
|
|
64
|
+
if (description !== undefined) {
|
|
65
|
+
copy.description = description
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const placeholder = pick(`${base}.placeholder`, (source as {
|
|
69
|
+
placeholder?: string
|
|
70
|
+
}).placeholder)
|
|
71
|
+
if (placeholder !== undefined) {
|
|
72
|
+
copy.placeholder = placeholder
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (source.type === 'select') {
|
|
76
|
+
copy.options = source.options.map((option) => ({
|
|
77
|
+
...option,
|
|
78
|
+
label: pick(`${base}.options.${option.value}`, option.label),
|
|
79
|
+
}))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return copy as F
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
get label() {
|
|
87
|
+
return label()
|
|
88
|
+
},
|
|
89
|
+
get labelPlural() {
|
|
90
|
+
return labelPlural()
|
|
91
|
+
},
|
|
92
|
+
get labelLower() {
|
|
93
|
+
return lower(label())
|
|
94
|
+
},
|
|
95
|
+
get labelPluralLower() {
|
|
96
|
+
return lower(labelPlural())
|
|
97
|
+
},
|
|
98
|
+
get newLabel() {
|
|
99
|
+
const key = `${prefix()}.new`
|
|
100
|
+
return i18n.te(key) ? i18n.t(key) : i18n.t('pluto.content.new', { label: label() })
|
|
101
|
+
},
|
|
102
|
+
fieldLabel,
|
|
103
|
+
field,
|
|
104
|
+
statusLabel: (value, literal) => pick(`${prefix()}.status.${value}`, literal ?? value),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Same as `createContentText`, for a type that is already in hand. */
|
|
109
|
+
export function contentTextFor(type: PlutoContentType, i18n: ContentTextI18n): PlutoContentText {
|
|
110
|
+
return createContentText(() => type, i18n)
|
|
111
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Pure part of `usePlutoErrorText`. It does not use Nuxt auto-imports.
|
|
2
|
+
import type { MessageParams } from './i18n-core'
|
|
3
|
+
|
|
4
|
+
/** The fallback key when the caller passes none. */
|
|
5
|
+
export const DEFAULT_ERROR_KEY = 'pluto.errors.unknown'
|
|
6
|
+
|
|
7
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
8
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function nonEmpty(value: unknown): string | undefined {
|
|
12
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Finds the `{ key, params }` that a `createPlutoError` call put in the
|
|
17
|
+
* response. A `$fetch` error holds the whole response body in `data`, so the
|
|
18
|
+
* pair sits in `data.data.i18n`. An error that Nuxt creates on the client
|
|
19
|
+
* holds it in `data.i18n`.
|
|
20
|
+
*/
|
|
21
|
+
function findTag(data: Record<string, unknown> | undefined): {
|
|
22
|
+
key: string
|
|
23
|
+
params?: MessageParams
|
|
24
|
+
} | undefined {
|
|
25
|
+
for (const candidate of [asRecord(data?.i18n), asRecord(asRecord(data?.data)?.i18n)]) {
|
|
26
|
+
if (candidate && typeof candidate.key === 'string') {
|
|
27
|
+
return { key: candidate.key, params: asRecord(candidate.params) }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Turns an error into text for an admin. Order:
|
|
35
|
+
*
|
|
36
|
+
* 1. The translated `data.i18n` of a server `createPlutoError`.
|
|
37
|
+
* 2. The `statusMessage`, then the `message`, of the response body
|
|
38
|
+
* (English). Then the `statusMessage` of the error itself. A `$fetch`
|
|
39
|
+
* error copies that one from the HTTP status text. The status text can
|
|
40
|
+
* be a generic phrase ("Unsupported Media Type") and is empty over
|
|
41
|
+
* HTTP/2, so the body comes first.
|
|
42
|
+
* 3. The translated fallback key.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveErrorText(
|
|
45
|
+
error: unknown,
|
|
46
|
+
fallbackKey: string,
|
|
47
|
+
i18n: {
|
|
48
|
+
t: (key: string, params?: MessageParams) => string
|
|
49
|
+
te: (key: string) => boolean
|
|
50
|
+
}
|
|
51
|
+
): string {
|
|
52
|
+
const source = asRecord(error)
|
|
53
|
+
const data = asRecord(source?.data)
|
|
54
|
+
|
|
55
|
+
const tag = findTag(data)
|
|
56
|
+
if (tag && i18n.te(tag.key)) {
|
|
57
|
+
return i18n.t(tag.key, tag.params)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const server = nonEmpty(data?.statusMessage) ?? nonEmpty(data?.message) ?? nonEmpty(source?.statusMessage)
|
|
61
|
+
return server ?? i18n.t(fallbackKey)
|
|
62
|
+
}
|