@plutocms/pluto 0.13.0 → 0.14.0

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/FEATURES.md +7 -0
  3. package/app/app.vue +5 -2
  4. package/app/components/ColorModeButton.vue +2 -1
  5. package/app/components/Pluto/Content/PlutoContentField.vue +17 -3
  6. package/app/components/Pluto/Content/PlutoContentForm.vue +60 -38
  7. package/app/components/Pluto/Content/PlutoContentList.vue +38 -32
  8. package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +2 -1
  9. package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +22 -19
  10. package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +15 -14
  11. package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +6 -5
  12. package/app/components/Pluto/PlutoDashboardWidgets.vue +2 -1
  13. package/app/components/Pluto/PlutoRoot.vue +18 -1
  14. package/app/components/Pluto/Settings/PlutoSettingsLanguage.vue +51 -0
  15. package/app/components/modal/ModalHeader.vue +3 -1
  16. package/app/composables/internal/content-text.ts +111 -0
  17. package/app/composables/internal/error-text.ts +58 -0
  18. package/app/composables/internal/i18n-core.ts +310 -0
  19. package/app/composables/internal/i18n-runtime.ts +87 -0
  20. package/app/composables/pluto-admin-nav.ts +21 -4
  21. package/app/composables/pluto-admin-pages.ts +13 -3
  22. package/app/composables/pluto-command-palette.ts +13 -6
  23. package/app/composables/pluto-content-actions.ts +9 -5
  24. package/app/composables/pluto-content-text.ts +28 -0
  25. package/app/composables/pluto-dashboard.ts +15 -2
  26. package/app/composables/pluto-error.ts +13 -0
  27. package/app/composables/pluto-extension.ts +4 -0
  28. package/app/composables/pluto-i18n.ts +171 -0
  29. package/app/composables/pluto-permissions.ts +16 -2
  30. package/app/composables/pluto-registry.ts +1 -0
  31. package/app/composables/pluto-settings.ts +16 -2
  32. package/app/layouts/admin.vue +2 -1
  33. package/app/pages/admin/content/[type]/[id].vue +3 -1
  34. package/app/pages/admin/content/[type]/index.vue +2 -1
  35. package/app/pages/admin/content/[type]/new.vue +2 -1
  36. package/app/pages/admin/home.vue +6 -4
  37. package/app/pages/admin/settings.vue +10 -5
  38. package/app/plugins/pluto-core-registrations.ts +10 -7
  39. package/app/plugins/pluto-i18n.ts +20 -0
  40. package/i18n/locales/en.json +137 -0
  41. package/i18n/locales/pt-BR.json +137 -0
  42. package/modules/pluto-i18n/index.ts +89 -0
  43. package/modules/pluto-i18n/locale-files.ts +187 -0
  44. package/package.json +1 -1
  45. package/server/utils/pluto-content-handlers.ts +30 -7
  46. package/server/utils/pluto-error.ts +27 -0
  47. package/shared/types/i18n.d.ts +10 -0
  48. package/shared/types/i18n.ts +14 -0
  49. package/shared/types/registry.ts +13 -0
  50. 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 Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
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.format(new Date(`${point.start}T00:00:00Z`)) }
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
- const categories = computed(() => series.value.map((entry) => ({ key: entry.name, label: entry.labelPlural })))
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">Content activity</h3>
49
- <p class="text-muted text-sm">Items created per week, last {{ WEEKS }} weeks</p>
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">{{ number.format(total) }}</p>
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: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
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="`Nothing created in the last ${WEEKS} weeks`"
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) => number.format(value)"
92
- :aria-label="`Items created per week, last ${WEEKS} weeks`"
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>Items created per week, last {{ WEEKS }} weeks</caption>
101
+ <caption>{{ t('pluto.dashboard.activity.subtitle', { weeks: WEEKS }) }}</caption>
99
102
  <thead>
100
103
  <tr>
101
- <th scope="col">Week of</th>
102
- <th v-for="entry in series" :key="entry.name" scope="col">{{ entry.labelPlural }}</th>
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 number = new Intl.NumberFormat()
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="Content overview">
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: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
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
- {{ number.format(stat.total) }}
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">+{{ number.format(thisWeek(stat)!) }}</span>
73
- <span class="text-muted"> this week</span>
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">none new this week</span>
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="`${stat.published} of ${stat.total} ${stat.labelPlural.toLowerCase()} published`"
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">{{ number.format(stat.published) }}</span>
93
- published ·
94
- <span class="tabular-nums">{{ number.format(stat.drafts ?? 0) }}</span>
95
- {{ stat.drafts === 1 ? 'draft' : '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 !== 'Home')
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">Quick actions</h3>
27
+ <h3 class="text-highlighted font-semibold">{{ t('pluto.dashboard.quickActions.title') }}</h3>
27
28
  <p class="text-muted text-sm">
28
- Press
29
+ {{ t('pluto.dashboard.quickActions.pressBefore') }}
29
30
  <UKbd value="meta" size="sm" />
30
31
  <UKbd value="k" size="sm" />
31
- to search everything.
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">Go to</p>
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
- No dashboard widgets are registered yet. A layer can add one with
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">Close</span>
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,58 @@
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 server `statusMessage`, then the server `message` (English).
38
+ * 3. The translated fallback key.
39
+ */
40
+ export function resolveErrorText(
41
+ error: unknown,
42
+ fallbackKey: string,
43
+ i18n: {
44
+ t: (key: string, params?: MessageParams) => string
45
+ te: (key: string) => boolean
46
+ }
47
+ ): string {
48
+ const source = asRecord(error)
49
+ const data = asRecord(source?.data)
50
+
51
+ const tag = findTag(data)
52
+ if (tag && i18n.te(tag.key)) {
53
+ return i18n.t(tag.key, tag.params)
54
+ }
55
+
56
+ const server = nonEmpty(source?.statusMessage) ?? nonEmpty(data?.statusMessage) ?? nonEmpty(data?.message)
57
+ return server ?? i18n.t(fallbackKey)
58
+ }