@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
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contentTextFor } from './internal/content-text'
|
|
2
|
+
|
|
1
3
|
// The old `NavigationMenuItem`/`NavigationMenuChildItem` shape (from
|
|
2
4
|
// `@plutocms/utils`) always carries a plain `href`, never Nuxt's typed `to`.
|
|
3
5
|
// This narrows just the field the bridge below reads.
|
|
@@ -22,6 +24,11 @@ interface LegacyHrefLink {
|
|
|
22
24
|
* when `type.capabilities.read` is unset, the entry is always visible;
|
|
23
25
|
* when it is set, `can(...)` decides.
|
|
24
26
|
*
|
|
27
|
+
* Labels are key-or-literal. They resolve with `tx()` after the legacy dedupe
|
|
28
|
+
* above, which compares the raw labels. A content type's labels come from
|
|
29
|
+
* `usePlutoContentText`, so the convention keys apply. The `computed` reads
|
|
30
|
+
* the active locale, so a locale switch updates the labels.
|
|
31
|
+
*
|
|
25
32
|
* `entry.enabled` is read and enforced uniformly across all three sources
|
|
26
33
|
* here. Source 1 already filters on it a second time, inside
|
|
27
34
|
* `createOwnedRegistry` itself (see
|
|
@@ -35,6 +42,8 @@ export function usePlutoAdminNav() {
|
|
|
35
42
|
const legacy = useSidebarAdminActions()
|
|
36
43
|
const { items: contentTypes } = usePlutoContentTypes()
|
|
37
44
|
const { can } = usePlutoPermissions()
|
|
45
|
+
const i18n = usePlutoI18n()
|
|
46
|
+
const { t, tx } = i18n
|
|
38
47
|
|
|
39
48
|
const items = computed<PlutoNavItem[]>(() => {
|
|
40
49
|
const registered = registry.nav.list.value
|
|
@@ -55,27 +64,35 @@ export function usePlutoAdminNav() {
|
|
|
55
64
|
})),
|
|
56
65
|
}))
|
|
57
66
|
|
|
67
|
+
// Resolve the labels now, after the dedupe above has compared the raw ones.
|
|
68
|
+
const resolveLabels = (item: PlutoNavItem): PlutoNavItem => ({
|
|
69
|
+
...item,
|
|
70
|
+
label: tx(item.label),
|
|
71
|
+
children: item.children?.map((child) => ({ ...child, label: tx(child.label) })),
|
|
72
|
+
})
|
|
73
|
+
|
|
58
74
|
const contentNav: PlutoNavItem[] = contentTypes.value
|
|
59
75
|
.filter((entry) => entry.type.autoRoutes !== false)
|
|
60
76
|
.map((entry) => {
|
|
61
77
|
const type = entry.type
|
|
78
|
+
const text = contentTextFor(type, i18n)
|
|
62
79
|
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
63
80
|
|
|
64
81
|
return {
|
|
65
82
|
id: `content:${type.name}`,
|
|
66
83
|
order: type.navOrder ?? 100,
|
|
67
|
-
label:
|
|
84
|
+
label: text.labelPlural,
|
|
68
85
|
icon: type.icon,
|
|
69
86
|
to: basePath,
|
|
70
87
|
enabled: () => !type.capabilities?.read || can(type.capabilities.read),
|
|
71
88
|
children: [
|
|
72
|
-
{ label:
|
|
73
|
-
{ label:
|
|
89
|
+
{ label: t('pluto.content.all', { labelPlural: text.labelPlural }), to: basePath },
|
|
90
|
+
{ label: t('pluto.content.createNew', { label: text.label }), to: `${basePath}/new` },
|
|
74
91
|
],
|
|
75
92
|
}
|
|
76
93
|
})
|
|
77
94
|
|
|
78
|
-
return [...registered, ...bridged, ...contentNav]
|
|
95
|
+
return [...registered.map(resolveLabels), ...bridged.map(resolveLabels), ...contentNav]
|
|
79
96
|
.filter((item) => item.enabled?.() ?? true)
|
|
80
97
|
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
81
98
|
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contentTextFor } from './internal/content-text'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Metadata for admin pages, registered on top of routes Nuxt already
|
|
3
5
|
* creates from files, plus two synthesized entries per auto-routed content
|
|
@@ -5,32 +7,40 @@
|
|
|
5
7
|
* page-metadata equivalent of the nav entry `usePlutoAdminNav` synthesizes
|
|
6
8
|
* for the same content type. No layer registers page metadata today, so
|
|
7
9
|
* the registry bucket itself has no legacy bridge.
|
|
10
|
+
*
|
|
11
|
+
* Titles are key-or-literal. They resolve with `tx()` inside the `computed`,
|
|
12
|
+
* so a locale switch updates them.
|
|
8
13
|
*/
|
|
9
14
|
export function usePlutoAdminPages() {
|
|
10
15
|
const registry = usePlutoRegistry()
|
|
11
16
|
const route = useRoute()
|
|
12
17
|
const { items: contentTypes } = usePlutoContentTypes()
|
|
18
|
+
const i18n = usePlutoI18n()
|
|
19
|
+
const { t, tx } = i18n
|
|
13
20
|
|
|
14
21
|
const items = computed<PlutoAdminPage[]>(() => {
|
|
15
22
|
const contentPages: PlutoAdminPage[] = contentTypes.value
|
|
16
23
|
.filter((entry) => entry.type.autoRoutes !== false)
|
|
17
24
|
.flatMap((entry) => {
|
|
18
25
|
const type = entry.type
|
|
26
|
+
const text = contentTextFor(type, i18n)
|
|
19
27
|
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
20
28
|
const navId = `content:${type.name}`
|
|
21
29
|
|
|
22
30
|
return [
|
|
23
|
-
{ id: `${navId}:list`, path: basePath, title:
|
|
31
|
+
{ id: `${navId}:list`, path: basePath, title: text.labelPlural },
|
|
24
32
|
{
|
|
25
33
|
id: `${navId}:new`,
|
|
26
34
|
path: `${basePath}/new`,
|
|
27
|
-
title:
|
|
35
|
+
title: t('pluto.content.createNew', { label: text.label }),
|
|
28
36
|
parent: navId,
|
|
29
37
|
},
|
|
30
38
|
]
|
|
31
39
|
})
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
const registered = registry.pages.list.value.map((page) => ({ ...page, title: tx(page.title) }))
|
|
42
|
+
|
|
43
|
+
return [...registered, ...contentPages]
|
|
34
44
|
})
|
|
35
45
|
|
|
36
46
|
const current = computed(() => items.value.find((page) => page.path === route.path))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommandPaletteGroup, CommandPaletteItem } from '@nuxt/ui'
|
|
2
|
+
import { contentTextFor } from './internal/content-text'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Builds the admin command palette groups (`UDashboardSearch` in the
|
|
@@ -19,7 +20,12 @@ export function usePlutoCommandPalette() {
|
|
|
19
20
|
const { items: navItems } = usePlutoAdminNav()
|
|
20
21
|
const { items: contentTypes } = usePlutoContentTypes()
|
|
21
22
|
const { can } = usePlutoPermissions()
|
|
23
|
+
const i18n = usePlutoI18n()
|
|
24
|
+
const { t, tx } = i18n
|
|
22
25
|
|
|
26
|
+
// Everything the palette shows resolves inside the `computed`, so a locale
|
|
27
|
+
// switch updates it. Nav labels arrive resolved from `usePlutoAdminNav`.
|
|
28
|
+
// `tx()` leaves a resolved label as it is.
|
|
23
29
|
return computed<CommandPaletteGroup<CommandPaletteItem>[]>(() => {
|
|
24
30
|
const seen = new Set<string>()
|
|
25
31
|
|
|
@@ -38,22 +44,23 @@ export function usePlutoCommandPalette() {
|
|
|
38
44
|
return []
|
|
39
45
|
}
|
|
40
46
|
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
41
|
-
|
|
47
|
+
const text = contentTextFor(type, i18n)
|
|
48
|
+
return command(text.newLabel, type.newPath ?? `${basePath}/new`, {
|
|
42
49
|
icon: 'lucide:plus',
|
|
43
|
-
suffix:
|
|
50
|
+
suffix: text.labelPlural,
|
|
44
51
|
})
|
|
45
52
|
})
|
|
46
53
|
|
|
47
54
|
const goTo = navItems.value.flatMap((item) => [
|
|
48
|
-
...(item.to ? command(item.label, item.to, { icon: item.icon }) : []),
|
|
55
|
+
...(item.to ? command(tx(item.label), item.to, { icon: item.icon }) : []),
|
|
49
56
|
...(item.children ?? []).flatMap((child) =>
|
|
50
|
-
child.to ? command(child.label, child.to, { icon: item.icon, suffix: item.label }) : []
|
|
57
|
+
child.to ? command(tx(child.label), child.to, { icon: item.icon, suffix: tx(item.label) }) : []
|
|
51
58
|
),
|
|
52
59
|
])
|
|
53
60
|
|
|
54
61
|
return [
|
|
55
|
-
{ id: 'pluto-create', label: '
|
|
56
|
-
{ id: 'pluto-go-to', label: '
|
|
62
|
+
{ id: 'pluto-create', label: t('pluto.common.create'), items: create },
|
|
63
|
+
{ id: 'pluto-go-to', label: t('pluto.palette.goTo'), items: goTo },
|
|
57
64
|
].filter((group) => group.items.length > 0)
|
|
58
65
|
})
|
|
59
66
|
}
|
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
* placement. Matches an entry whose `contentType` is `typeName` or `'*'`,
|
|
4
4
|
* the same matching rule `usePlutoContentFieldWidget` uses for field type.
|
|
5
5
|
* `placement` defaults to 'toolbar', matching `PlutoContentAction.placement`.
|
|
6
|
+
* The label is key-or-literal. It resolves with `tx()` inside the `computed`.
|
|
6
7
|
*/
|
|
7
8
|
export function usePlutoContentActions(typeName: string, placement: 'toolbar' | 'aside' = 'toolbar') {
|
|
8
9
|
const registry = usePlutoRegistry()
|
|
10
|
+
const { tx } = usePlutoI18n()
|
|
9
11
|
|
|
10
12
|
return computed(() =>
|
|
11
|
-
registry.contentActions.list.value
|
|
12
|
-
(
|
|
13
|
-
(action
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
registry.contentActions.list.value
|
|
14
|
+
.filter(
|
|
15
|
+
(action) =>
|
|
16
|
+
(action.contentType === typeName || action.contentType === '*') &&
|
|
17
|
+
(action.placement ?? 'toolbar') === placement
|
|
18
|
+
)
|
|
19
|
+
.map((action) => ({ ...action, label: tx(action.label) }))
|
|
16
20
|
)
|
|
17
21
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContentText } from './internal/content-text'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The translated text of one content type. Each value is the convention key
|
|
5
|
+
* (`content.<name>.label`, `.labelPlural`, `.new`, `.fields.<field>.label`
|
|
6
|
+
* and more) when the key exists, else the English literal of the type. A type
|
|
7
|
+
* with no keys keeps its English text.
|
|
8
|
+
*
|
|
9
|
+
* Read the values inside a template or a `computed`, so a locale switch
|
|
10
|
+
* updates them. Server code and code that reads `type.label` directly keep
|
|
11
|
+
* the English text.
|
|
12
|
+
*/
|
|
13
|
+
export function usePlutoContentText(type: MaybeRefOrGetter<PlutoContentType | undefined>) {
|
|
14
|
+
const { t, te, locale } = usePlutoI18n()
|
|
15
|
+
return createContentText(() => toValue(type), { t, te, locale })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns a function that gives the translated text of a content type by its
|
|
20
|
+
* `name`. Use it where one component shows many types, such as the dashboard
|
|
21
|
+
* tiles. See `usePlutoContentText`.
|
|
22
|
+
*/
|
|
23
|
+
export function usePlutoContentTexts() {
|
|
24
|
+
const { t, te, locale } = usePlutoI18n()
|
|
25
|
+
const { byName } = usePlutoContentTypes()
|
|
26
|
+
|
|
27
|
+
return (name: string) => createContentText(() => byName(name), { t, te, locale })
|
|
28
|
+
}
|
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Widgets shown on the admin dashboard (`/admin/home`). A widget title is
|
|
3
|
+
* key-or-literal. It resolves with `tx()` inside the `computed`, so a locale
|
|
4
|
+
* switch updates it.
|
|
5
|
+
*/
|
|
2
6
|
export function usePlutoDashboardWidgets() {
|
|
3
7
|
const registry = usePlutoRegistry()
|
|
4
|
-
|
|
8
|
+
const { tx } = usePlutoI18n()
|
|
9
|
+
|
|
10
|
+
const items = computed<PlutoDashboardWidget[]>(() =>
|
|
11
|
+
registry.dashboardWidgets.list.value.map((widget) => ({
|
|
12
|
+
...widget,
|
|
13
|
+
title: widget.title === undefined ? undefined : tx(widget.title),
|
|
14
|
+
}))
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return { items }
|
|
5
18
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DEFAULT_ERROR_KEY, resolveErrorText } from './internal/error-text'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a function that turns an error into text for the admin. It prefers
|
|
5
|
+
* the translation key that a server `createPlutoError` sent (`data.i18n`),
|
|
6
|
+
* then the server `statusMessage` or `message`, then the fallback key. The
|
|
7
|
+
* default fallback is `pluto.errors.unknown`.
|
|
8
|
+
*/
|
|
9
|
+
export function usePlutoErrorText() {
|
|
10
|
+
const { t, te } = usePlutoI18n()
|
|
11
|
+
|
|
12
|
+
return (error: unknown, fallbackKey: string = DEFAULT_ERROR_KEY): string => resolveErrorText(error, fallbackKey, { t, te })
|
|
13
|
+
}
|
|
@@ -20,6 +20,10 @@ export function definePlutoExtension(definition: PlutoExtension): void {
|
|
|
20
20
|
definition.id,
|
|
21
21
|
definition.settingsDriver ? [definition.settingsDriver] : []
|
|
22
22
|
)
|
|
23
|
+
registry.localeDrivers.set(
|
|
24
|
+
definition.id,
|
|
25
|
+
definition.localeDriver ? [definition.localeDriver] : []
|
|
26
|
+
)
|
|
23
27
|
registry.dashboardWidgets.set(definition.id, definition.dashboardWidgets ?? [])
|
|
24
28
|
registry.capabilities.set(definition.id, definition.capabilities ?? [])
|
|
25
29
|
registry.permissionsDrivers.set(
|