@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/FEATURES.md +7 -0
  3. package/app/app.vue +5 -2
  4. package/app/assets/css/admin-theme.css +16 -7
  5. package/app/components/ColorModeButton.vue +2 -1
  6. package/app/components/Pluto/Content/PlutoContentField.vue +17 -3
  7. package/app/components/Pluto/Content/PlutoContentForm.vue +60 -38
  8. package/app/components/Pluto/Content/PlutoContentList.vue +84 -42
  9. package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +2 -1
  10. package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +22 -19
  11. package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +15 -14
  12. package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +6 -5
  13. package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +4 -1
  14. package/app/components/Pluto/PlutoDashboardWidgets.vue +2 -1
  15. package/app/components/Pluto/PlutoRoot.vue +18 -1
  16. package/app/components/Pluto/Settings/PlutoSettingsLanguage.vue +51 -0
  17. package/app/components/Pluto/View/PlutoViewAdminPanel.vue +9 -1
  18. package/app/components/Pluto/View/PlutoViewAside.vue +14 -7
  19. package/app/components/modal/Modal.vue +1 -1
  20. package/app/components/modal/ModalHeader.vue +3 -1
  21. package/app/composables/internal/content-text.ts +111 -0
  22. package/app/composables/internal/error-text.ts +58 -0
  23. package/app/composables/internal/i18n-core.ts +310 -0
  24. package/app/composables/internal/i18n-runtime.ts +87 -0
  25. package/app/composables/pluto-admin-nav.ts +21 -4
  26. package/app/composables/pluto-admin-pages.ts +13 -3
  27. package/app/composables/pluto-command-palette.ts +13 -6
  28. package/app/composables/pluto-content-actions.ts +9 -5
  29. package/app/composables/pluto-content-list.ts +88 -7
  30. package/app/composables/pluto-content-text.ts +28 -0
  31. package/app/composables/pluto-dashboard.ts +15 -2
  32. package/app/composables/pluto-error.ts +13 -0
  33. package/app/composables/pluto-extension.ts +4 -0
  34. package/app/composables/pluto-i18n.ts +171 -0
  35. package/app/composables/pluto-permissions.ts +16 -2
  36. package/app/composables/pluto-registry.ts +1 -0
  37. package/app/composables/pluto-settings.ts +16 -2
  38. package/app/layouts/admin.vue +2 -1
  39. package/app/pages/admin/content/[type]/[id].vue +3 -1
  40. package/app/pages/admin/content/[type]/index.vue +2 -1
  41. package/app/pages/admin/content/[type]/new.vue +2 -1
  42. package/app/pages/admin/home.vue +6 -4
  43. package/app/pages/admin/settings.vue +10 -5
  44. package/app/plugins/pluto-core-registrations.ts +10 -7
  45. package/app/plugins/pluto-i18n.ts +20 -0
  46. package/i18n/locales/en.json +137 -0
  47. package/i18n/locales/pt-BR.json +137 -0
  48. package/modules/pluto-i18n/index.ts +89 -0
  49. package/modules/pluto-i18n/locale-files.ts +187 -0
  50. package/package.json +1 -1
  51. package/scripts/generate-admin-theme.cjs +21 -7
  52. package/server/utils/pluto-content-handlers.ts +30 -7
  53. package/server/utils/pluto-error.ts +27 -0
  54. package/shared/types/i18n.d.ts +10 -0
  55. package/shared/types/i18n.ts +14 -0
  56. package/shared/types/registry.ts +13 -0
  57. 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="min-h-0 w-full overflow-y-auto">
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
- // Open the slideover on mobile only. A `lg:hidden` class alone is not
30
- // enough: an open slideover is a modal dialog, so it disables pointer
31
- // events on the rest of the page even while it is hidden. On desktop, the
32
- // page then stops taking clicks as soon as the aside opens. It also mounts
33
- // the slot a second time.
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="hidden h-full flex-col lg:flex"
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>
@@ -40,7 +40,7 @@ const resolvedClasses = computed(() => {
40
40
  my-2 mx-2 sm:my-4 sm:mx-4
41
41
  shadow-2xl
42
42
  bg-default
43
- light:text-zinc-800 dark:text-white
43
+ text-default
44
44
  `
45
45
 
46
46
  return twMerge([
@@ -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
+ }
@@ -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
+ }