@plutocms/pluto 0.12.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.
- package/CHANGELOG.md +24 -0
- package/FEATURES.md +7 -0
- package/app/app.vue +5 -2
- package/app/assets/css/admin-theme.css +16 -7
- 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 +84 -42
- 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/Navbar/PlutoNavbarAdmin.vue +4 -1
- 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/Pluto/View/PlutoViewAdminPanel.vue +9 -1
- package/app/components/Pluto/View/PlutoViewAside.vue +14 -7
- package/app/components/modal/Modal.vue +1 -1
- package/app/components/modal/ModalHeader.vue +3 -1
- package/app/composables/internal/content-text.ts +111 -0
- package/app/composables/internal/error-text.ts +58 -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-list.ts +88 -7
- 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 +1 -1
- package/scripts/generate-admin-theme.cjs +21 -7
- 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
|
@@ -11,10 +11,17 @@ const props = withDefaults(
|
|
|
11
11
|
* resizable panel. The panel width persists across visits.
|
|
12
12
|
*/
|
|
13
13
|
asideOpen?: boolean
|
|
14
|
+
/**
|
|
15
|
+
* Fill the main region's height, and let the content scroll inside its
|
|
16
|
+
* own region, not the whole page. For a view with one long scrolling
|
|
17
|
+
* list, such as a virtualized table. Default false.
|
|
18
|
+
*/
|
|
19
|
+
fill?: boolean
|
|
14
20
|
}>(),
|
|
15
21
|
{
|
|
16
22
|
width: 'container',
|
|
17
23
|
asideOpen: false,
|
|
24
|
+
fill: false,
|
|
18
25
|
}
|
|
19
26
|
)
|
|
20
27
|
|
|
@@ -86,7 +93,7 @@ const items = computed<SplitterItem[]>(() => [
|
|
|
86
93
|
@dragging="onDragging"
|
|
87
94
|
>
|
|
88
95
|
<template #main>
|
|
89
|
-
<main class="
|
|
96
|
+
<main :class="fill ? 'flex flex-col overflow-hidden' : 'overflow-y-auto'" class="min-h-0 w-full">
|
|
90
97
|
<!--
|
|
91
98
|
'container' matches AdminView's own padding/gap classes, so a view
|
|
92
99
|
moving from AdminView to this panel looks the same. 'full' adds
|
|
@@ -95,6 +102,7 @@ const items = computed<SplitterItem[]>(() => [
|
|
|
95
102
|
-->
|
|
96
103
|
<UContainer
|
|
97
104
|
v-if="width === 'container'"
|
|
105
|
+
:class="{ 'h-full min-h-0': fill }"
|
|
98
106
|
class="flex max-w-300 flex-col gap-y-6 px-4 py-6 sm:px-6 lg:gap-y-8 lg:px-8 lg:py-10"
|
|
99
107
|
>
|
|
100
108
|
<slot />
|
|
@@ -26,11 +26,11 @@ function close() {
|
|
|
26
26
|
open.value = false
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// the
|
|
29
|
+
// Render the plain aside on desktop only, and the slideover on mobile only,
|
|
30
|
+
// so the slot mounts once. CSS classes (`hidden lg:flex`, `lg:hidden`) are
|
|
31
|
+
// not enough: they hide a copy, but the copy stays mounted and fetches its
|
|
32
|
+
// own data. An open slideover is also a modal dialog. It disables pointer
|
|
33
|
+
// events on the rest of the page even while CSS hides it.
|
|
34
34
|
const isDesktop = useMediaQuery('(min-width: 64rem)')
|
|
35
35
|
const resizable = inject(plutoViewAsideResizableKey, ref(false))
|
|
36
36
|
const slideoverOpen = computed({
|
|
@@ -47,10 +47,10 @@ const slideoverOpen = computed({
|
|
|
47
47
|
width and its handle draws the border. Elsewhere, keep the fixed width.
|
|
48
48
|
-->
|
|
49
49
|
<aside
|
|
50
|
-
v-if="open"
|
|
50
|
+
v-if="open && isDesktop"
|
|
51
51
|
:style="resizable ? undefined : { width: `${width}px` }"
|
|
52
52
|
:class="resizable ? 'w-full' : 'border-default shrink-0 border-s'"
|
|
53
|
-
class="
|
|
53
|
+
class="flex h-full flex-col"
|
|
54
54
|
>
|
|
55
55
|
<div class="border-default flex h-12 shrink-0 items-center justify-between gap-1.5 border-b px-4">
|
|
56
56
|
<h2 class="text-highlighted truncate text-sm font-semibold">{{ title }}</h2>
|
|
@@ -67,9 +67,16 @@ const slideoverOpen = computed({
|
|
|
67
67
|
</div>
|
|
68
68
|
</aside>
|
|
69
69
|
|
|
70
|
+
<!--
|
|
71
|
+
`unmount-on-hide="false"` keeps the slot mounted after the first open,
|
|
72
|
+
so a reopen keeps the widget state (an open picker, a typed search) and
|
|
73
|
+
does not fetch the field options again.
|
|
74
|
+
-->
|
|
70
75
|
<USlideover
|
|
76
|
+
v-if="!isDesktop"
|
|
71
77
|
v-model:open="slideoverOpen"
|
|
72
78
|
:title="title"
|
|
79
|
+
:unmount-on-hide="false"
|
|
73
80
|
side="right"
|
|
74
81
|
>
|
|
75
82
|
<template #body>
|
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Pure engine of the Pluto i18n system. It does not use Nuxt auto-imports, so
|
|
2
|
+
// plain Vitest can import it. The Nuxt-aware wrappers are in
|
|
3
|
+
// `app/composables/pluto-i18n.ts`. Import from 'vue' explicitly, as
|
|
4
|
+
// `registry-factory.ts` does.
|
|
5
|
+
import { shallowReactive } from 'vue'
|
|
6
|
+
|
|
7
|
+
/** A message tree as stored in a locale JSON file. */
|
|
8
|
+
export interface MessageTree {
|
|
9
|
+
[key: string]: string | MessageTree
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A message tree flattened to dotted keys. */
|
|
13
|
+
export type FlatMessages = Record<string, string>
|
|
14
|
+
|
|
15
|
+
export type MessageParams = Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A translation key: a lowercase namespace, then one or more dotted parts.
|
|
19
|
+
* Example: `pluto.common.save`. A plain English label does not match, so
|
|
20
|
+
* `tx()` can tell a key from a literal.
|
|
21
|
+
*/
|
|
22
|
+
const MESSAGE_KEY = /^[a-z][a-z0-9-]*(?:\.[\w-]+)+$/
|
|
23
|
+
|
|
24
|
+
export function isMessageKey(value: unknown): value is string {
|
|
25
|
+
return typeof value === 'string' && MESSAGE_KEY.test(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Flattens a nested message tree to dotted keys. Keeps string leaves only. */
|
|
29
|
+
export function flattenMessages(tree: MessageTree, prefix = ''): FlatMessages {
|
|
30
|
+
const flat: FlatMessages = {}
|
|
31
|
+
for (const [name, value] of Object.entries(tree)) {
|
|
32
|
+
const key = prefix ? `${prefix}.${name}` : name
|
|
33
|
+
if (typeof value === 'string') {
|
|
34
|
+
flat[key] = value
|
|
35
|
+
} else if (value && typeof value === 'object') {
|
|
36
|
+
Object.assign(flat, flattenMessages(value, key))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return flat
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Replaces each `{name}` with `params.name`. An unknown name stays as it is. */
|
|
43
|
+
export function interpolate(message: string, params?: MessageParams): string {
|
|
44
|
+
if (!params) {
|
|
45
|
+
return message
|
|
46
|
+
}
|
|
47
|
+
return message.replace(/\{(\w+)\}/g, (match, name: string) => {
|
|
48
|
+
const value = params[name]
|
|
49
|
+
return value === undefined || value === null ? match : String(value)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PLURAL_SEPARATOR = ' | '
|
|
54
|
+
|
|
55
|
+
/** Returns the number of plural forms in a message. */
|
|
56
|
+
export function countPluralForms(message: string): number {
|
|
57
|
+
return message.split(PLURAL_SEPARATOR).length
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pluralRules = new Map<string, Intl.PluralRules>()
|
|
61
|
+
|
|
62
|
+
function getPluralRules(locale: string): Intl.PluralRules {
|
|
63
|
+
let rules = pluralRules.get(locale)
|
|
64
|
+
if (!rules) {
|
|
65
|
+
rules = new Intl.PluralRules(locale)
|
|
66
|
+
pluralRules.set(locale, rules)
|
|
67
|
+
}
|
|
68
|
+
return rules
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Picks one form of a plural message.
|
|
73
|
+
*
|
|
74
|
+
* - One form: returned as it is.
|
|
75
|
+
* - Two forms (`one | other`): `Intl.PluralRules` of the locale decides.
|
|
76
|
+
* - Three forms (`zero | one | other`): 0 gives the first form, even in a
|
|
77
|
+
* locale where 0 is `one` (pt-BR). Then `Intl.PluralRules` decides.
|
|
78
|
+
*/
|
|
79
|
+
export function selectPlural(message: string, count: number, locale: string): string {
|
|
80
|
+
const forms = message.split(PLURAL_SEPARATOR)
|
|
81
|
+
if (forms.length === 1) {
|
|
82
|
+
return message
|
|
83
|
+
}
|
|
84
|
+
const category = getPluralRules(locale).select(count)
|
|
85
|
+
if (forms.length === 2) {
|
|
86
|
+
return category === 'one' ? forms[0]! : forms[1]!
|
|
87
|
+
}
|
|
88
|
+
if (count === 0) {
|
|
89
|
+
return forms[0]!
|
|
90
|
+
}
|
|
91
|
+
return category === 'one' ? forms[1]! : forms[2]!
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TranslatorOptions {
|
|
95
|
+
/** The active locale code. Called on each translation, so it can be reactive. */
|
|
96
|
+
locale: () => string
|
|
97
|
+
/** The locale to use when a key is missing. Default `en`. */
|
|
98
|
+
fallbackLocale?: string
|
|
99
|
+
/** Returns the flat messages of a locale, or undefined if not loaded. */
|
|
100
|
+
messages: (locale: string) => FlatMessages | undefined
|
|
101
|
+
/** Called once for each missing key. Default: `console.warn` in dev, none otherwise. */
|
|
102
|
+
warn?: (message: string) => void
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Translator {
|
|
106
|
+
/** Translates a key. A second number argument is the plural count. */
|
|
107
|
+
t: (key: string, params?: MessageParams | number) => string
|
|
108
|
+
/** True if the key exists in the active locale or in the fallback. */
|
|
109
|
+
te: (key: string) => boolean
|
|
110
|
+
/** Translates a key-or-literal value. A literal returns unchanged. */
|
|
111
|
+
tx: (value: string, params?: MessageParams | number) => string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Builds a translator. Fallback order: the active locale, the fallback
|
|
116
|
+
* locale, then the key itself.
|
|
117
|
+
*/
|
|
118
|
+
export function createTranslator(options: TranslatorOptions): Translator {
|
|
119
|
+
const fallbackLocale = options.fallbackLocale ?? 'en'
|
|
120
|
+
const warned = new Set<string>()
|
|
121
|
+
const warn = options.warn ?? ((message: string) => {
|
|
122
|
+
if (import.meta.dev) {
|
|
123
|
+
console.warn(message)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
function lookup(key: string): {
|
|
128
|
+
message: string
|
|
129
|
+
locale: string
|
|
130
|
+
} | undefined {
|
|
131
|
+
const locale = options.locale()
|
|
132
|
+
const active = options.messages(locale)?.[key]
|
|
133
|
+
if (active !== undefined) {
|
|
134
|
+
return { message: active, locale }
|
|
135
|
+
}
|
|
136
|
+
const fallback = options.messages(fallbackLocale)?.[key]
|
|
137
|
+
if (fallback !== undefined) {
|
|
138
|
+
return { message: fallback, locale: fallbackLocale }
|
|
139
|
+
}
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function t(key: string, params?: MessageParams | number): string {
|
|
144
|
+
const found = lookup(key)
|
|
145
|
+
if (!found) {
|
|
146
|
+
if (!warned.has(key)) {
|
|
147
|
+
warned.add(key)
|
|
148
|
+
warn(`[pluto-i18n] Missing translation key "${key}".`)
|
|
149
|
+
}
|
|
150
|
+
return key
|
|
151
|
+
}
|
|
152
|
+
const values: MessageParams = typeof params === 'number' ? { count: params } : (params ?? {})
|
|
153
|
+
let message = found.message
|
|
154
|
+
if (message.includes(PLURAL_SEPARATOR)) {
|
|
155
|
+
const count = Number(values.count)
|
|
156
|
+
message = selectPlural(message, Number.isNaN(count) ? 1 : count, found.locale)
|
|
157
|
+
}
|
|
158
|
+
return interpolate(message, values)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function te(key: string): boolean {
|
|
162
|
+
return lookup(key) !== undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function tx(value: string, params?: MessageParams | number): string {
|
|
166
|
+
return isMessageKey(value) && te(value) ? t(value, params) : value
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { t, te, tx }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Deep-merges message trees. A later tree overrides an earlier one. */
|
|
173
|
+
export function mergeMessageTrees(...trees: MessageTree[]): MessageTree {
|
|
174
|
+
const result: MessageTree = {}
|
|
175
|
+
for (const tree of trees) {
|
|
176
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
177
|
+
const current = result[key]
|
|
178
|
+
if (typeof value === 'object' && value !== null) {
|
|
179
|
+
result[key] = mergeMessageTrees(typeof current === 'object' && current !== null ? current : {}, value)
|
|
180
|
+
} else {
|
|
181
|
+
result[key] = value
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return result
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface LocaleCache<T> {
|
|
189
|
+
/** Reactive read. Returns undefined until the locale is loaded. */
|
|
190
|
+
get: (locale: string) => T | undefined
|
|
191
|
+
/** Loads a locale once. Many calls share one promise. */
|
|
192
|
+
load: (locale: string) => Promise<void>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A cache of per-locale data (messages, or a Nuxt UI locale object), filled
|
|
197
|
+
* by async loaders. `get` is reactive, so a translation updates when its
|
|
198
|
+
* locale finishes loading.
|
|
199
|
+
*/
|
|
200
|
+
export function createLocaleCache<T, R = T>(
|
|
201
|
+
loaders: Record<string, () => Promise<R>>,
|
|
202
|
+
transform: (value: R) => T
|
|
203
|
+
): LocaleCache<T> {
|
|
204
|
+
const store = shallowReactive(new Map<string, T>())
|
|
205
|
+
const pending = new Map<string, Promise<void>>()
|
|
206
|
+
|
|
207
|
+
function load(locale: string): Promise<void> {
|
|
208
|
+
if (store.has(locale)) {
|
|
209
|
+
return Promise.resolve()
|
|
210
|
+
}
|
|
211
|
+
const loader = loaders[locale]
|
|
212
|
+
if (!loader) {
|
|
213
|
+
return Promise.resolve()
|
|
214
|
+
}
|
|
215
|
+
let promise = pending.get(locale)
|
|
216
|
+
if (!promise) {
|
|
217
|
+
promise = loader()
|
|
218
|
+
.then((value) => {
|
|
219
|
+
store.set(locale, transform(value))
|
|
220
|
+
})
|
|
221
|
+
.finally(() => {
|
|
222
|
+
pending.delete(locale)
|
|
223
|
+
})
|
|
224
|
+
pending.set(locale, promise)
|
|
225
|
+
}
|
|
226
|
+
return promise
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { get: (locale) => store.get(locale), load }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const numberFormats = new Map<string, Intl.NumberFormat>()
|
|
233
|
+
const dateFormats = new Map<string, Intl.DateTimeFormat>()
|
|
234
|
+
|
|
235
|
+
/** Formats a number. The `Intl` formatter is cached per locale and options. */
|
|
236
|
+
export function formatNumber(locale: string, value: number, options?: Intl.NumberFormatOptions): string {
|
|
237
|
+
const key = `${locale}|${JSON.stringify(options ?? {})}`
|
|
238
|
+
let format = numberFormats.get(key)
|
|
239
|
+
if (!format) {
|
|
240
|
+
format = new Intl.NumberFormat(locale, options)
|
|
241
|
+
numberFormats.set(key, format)
|
|
242
|
+
}
|
|
243
|
+
return format.format(value)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Formats a date. The `Intl` formatter is cached per locale and options. */
|
|
247
|
+
export function formatDate(locale: string, value: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
|
|
248
|
+
const key = `${locale}|${JSON.stringify(options ?? {})}`
|
|
249
|
+
let format = dateFormats.get(key)
|
|
250
|
+
if (!format) {
|
|
251
|
+
format = new Intl.DateTimeFormat(locale, options)
|
|
252
|
+
dateFormats.set(key, format)
|
|
253
|
+
}
|
|
254
|
+
return format.format(value instanceof Date ? value : new Date(value))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Returns the name of a language in its own language, first letter in
|
|
259
|
+
* capitals. Example: `pt-BR` gives "Português (Brasil)".
|
|
260
|
+
*/
|
|
261
|
+
export function getLanguageName(code: string): string {
|
|
262
|
+
let name = code
|
|
263
|
+
try {
|
|
264
|
+
name = new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code
|
|
265
|
+
} catch {
|
|
266
|
+
// An invalid code keeps the code as the name.
|
|
267
|
+
}
|
|
268
|
+
return name.charAt(0).toLocaleUpperCase(code) + name.slice(1)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Parses an `Accept-Language` header into language tags, best first. A tag
|
|
273
|
+
* with `q=0` is dropped.
|
|
274
|
+
*/
|
|
275
|
+
export function parseAcceptLanguage(header: string | undefined): string[] {
|
|
276
|
+
if (!header) {
|
|
277
|
+
return []
|
|
278
|
+
}
|
|
279
|
+
return header
|
|
280
|
+
.split(',')
|
|
281
|
+
.map((part, index) => {
|
|
282
|
+
const [tag = '', ...params] = part.trim().split(';')
|
|
283
|
+
const q = params.map((param) => /^\s*q=([\d.]+)\s*$/.exec(param)?.[1]).find(Boolean)
|
|
284
|
+
return { tag: tag.trim(), q: q === undefined ? 1 : Number(q), index }
|
|
285
|
+
})
|
|
286
|
+
.filter((entry) => entry.tag && entry.tag !== '*' && entry.q > 0)
|
|
287
|
+
.sort((a, b) => b.q - a.q || a.index - b.index)
|
|
288
|
+
.map((entry) => entry.tag)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Matches the requested language tags to the supported locale codes. For each
|
|
293
|
+
* tag, in order: an exact match (ignoring case), then the first supported
|
|
294
|
+
* code with the same base language. Returns null if nothing matches.
|
|
295
|
+
*/
|
|
296
|
+
export function matchLocale(requested: string[], supported: string[]): string | null {
|
|
297
|
+
const lower = new Map(supported.map((code) => [code.toLowerCase(), code]))
|
|
298
|
+
for (const tag of requested) {
|
|
299
|
+
const exact = lower.get(tag.toLowerCase())
|
|
300
|
+
if (exact) {
|
|
301
|
+
return exact
|
|
302
|
+
}
|
|
303
|
+
const base = tag.toLowerCase().split('-')[0]!
|
|
304
|
+
const byBase = supported.find((code) => code.toLowerCase().split('-')[0] === base)
|
|
305
|
+
if (byBase) {
|
|
306
|
+
return byBase
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The Nuxt-aware part of the i18n engine: the shared caches, the language
|
|
2
|
+
// detection, and the first-locale resolve. It sits under `internal/` so it
|
|
3
|
+
// stays out of auto-import. The pure part is in `i18n-core.ts`.
|
|
4
|
+
import type { Locale, Messages } from '@nuxt/ui'
|
|
5
|
+
import type { FlatMessages, MessageTree } from './i18n-core'
|
|
6
|
+
import { markRaw } from 'vue'
|
|
7
|
+
import { defaultLocale, messageLoaders, uiLocaleLoaders } from '#build/pluto-i18n.mjs'
|
|
8
|
+
import { useCookie, useRequestHeaders, useRuntimeConfig } from '#imports'
|
|
9
|
+
import { createLocaleCache, flattenMessages, matchLocale, parseAcceptLanguage } from './i18n-core'
|
|
10
|
+
|
|
11
|
+
/** Cookie names. The admin and the site keep a separate locale. */
|
|
12
|
+
export const ADMIN_LOCALE_COOKIE = 'pluto-admin-locale'
|
|
13
|
+
export const SITE_LOCALE_COOKIE = 'pluto-site-locale'
|
|
14
|
+
|
|
15
|
+
const COOKIE_OPTIONS = { maxAge: 60 * 60 * 24 * 365, path: '/', sameSite: 'lax' } as const
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One message cache for both scopes. Messages never change per request, so
|
|
19
|
+
* a module-level cache is safe on the server too.
|
|
20
|
+
*/
|
|
21
|
+
export const messageCache = createLocaleCache<FlatMessages, MessageTree>(
|
|
22
|
+
messageLoaders as Record<string, () => Promise<MessageTree>>,
|
|
23
|
+
flattenMessages
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/** The Nuxt UI locale objects, for `UApp :locale`. */
|
|
27
|
+
export const uiLocaleCache = createLocaleCache<Locale<Messages>>(
|
|
28
|
+
uiLocaleLoaders as Record<string, () => Promise<Locale<Messages>>>,
|
|
29
|
+
(locale) => markRaw(locale)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
/** Loads the messages and the Nuxt UI locale of a locale, and of `en` for the fallback. */
|
|
33
|
+
export async function loadPlutoLocale(code: string): Promise<void> {
|
|
34
|
+
await Promise.all([
|
|
35
|
+
messageCache.load(code),
|
|
36
|
+
messageCache.load('en'),
|
|
37
|
+
uiLocaleCache.load(code),
|
|
38
|
+
uiLocaleCache.load('en'),
|
|
39
|
+
])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getConfig() {
|
|
43
|
+
return useRuntimeConfig().public.plutoI18n
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useLocaleCookie(name: string) {
|
|
47
|
+
return useCookie<string | null>(name, { ...COOKIE_OPTIONS, default: () => null })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The visitor's language tags, best first. On the server, from the
|
|
52
|
+
* `Accept-Language` header. On the client, from `navigator.languages`. The
|
|
53
|
+
* server never reads `navigator`.
|
|
54
|
+
*/
|
|
55
|
+
export function getBrowserLanguages(): string[] {
|
|
56
|
+
if (import.meta.server) {
|
|
57
|
+
return parseAcceptLanguage(useRequestHeaders(['accept-language'])['accept-language'])
|
|
58
|
+
}
|
|
59
|
+
return typeof navigator === 'undefined' ? [] : [...navigator.languages]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The admin locale from the browser language alone: no cookie, no stored choice. */
|
|
63
|
+
export function detectAdminLocale(): string {
|
|
64
|
+
const config = getConfig()
|
|
65
|
+
return matchLocale(getBrowserLanguages(), config.adminLocales) ?? config.defaultLocale
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A stored admin choice, or null. Only a supported code counts. */
|
|
69
|
+
export function readAdminPreference(): string | null {
|
|
70
|
+
const value = useLocaleCookie(ADMIN_LOCALE_COOKIE).value
|
|
71
|
+
return value && getConfig().adminLocales.includes(value) ? value : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Admin order: cookie, browser language, default locale. */
|
|
75
|
+
export function resolveAdminLocale(): string {
|
|
76
|
+
return readAdminPreference() ?? detectAdminLocale()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Site order: cookie, the `siteLocale` option, default locale. */
|
|
80
|
+
export function resolveSiteLocale(): string {
|
|
81
|
+
const config = getConfig()
|
|
82
|
+
const cookie = useLocaleCookie(SITE_LOCALE_COOKIE).value
|
|
83
|
+
if (cookie && config.siteLocales.includes(cookie)) {
|
|
84
|
+
return cookie
|
|
85
|
+
}
|
|
86
|
+
return config.siteLocale || config.defaultLocale || defaultLocale
|
|
87
|
+
}
|