@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.
Files changed (51) hide show
  1. package/.prettierignore +2 -0
  2. package/CHANGELOG.md +15 -0
  3. package/FEATURES.md +7 -0
  4. package/app/app.vue +5 -2
  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 +38 -32
  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/PlutoDashboardWidgets.vue +2 -1
  14. package/app/components/Pluto/PlutoRoot.vue +18 -1
  15. package/app/components/Pluto/Settings/PlutoSettingsLanguage.vue +51 -0
  16. package/app/components/modal/ModalHeader.vue +3 -1
  17. package/app/composables/internal/content-text.ts +111 -0
  18. package/app/composables/internal/error-text.ts +62 -0
  19. package/app/composables/internal/i18n-core.ts +310 -0
  20. package/app/composables/internal/i18n-runtime.ts +87 -0
  21. package/app/composables/pluto-admin-nav.ts +21 -4
  22. package/app/composables/pluto-admin-pages.ts +13 -3
  23. package/app/composables/pluto-command-palette.ts +13 -6
  24. package/app/composables/pluto-content-actions.ts +9 -5
  25. package/app/composables/pluto-content-text.ts +28 -0
  26. package/app/composables/pluto-dashboard.ts +15 -2
  27. package/app/composables/pluto-error.ts +13 -0
  28. package/app/composables/pluto-extension.ts +4 -0
  29. package/app/composables/pluto-i18n.ts +171 -0
  30. package/app/composables/pluto-permissions.ts +16 -2
  31. package/app/composables/pluto-registry.ts +1 -0
  32. package/app/composables/pluto-settings.ts +16 -2
  33. package/app/layouts/admin.vue +2 -1
  34. package/app/pages/admin/content/[type]/[id].vue +3 -1
  35. package/app/pages/admin/content/[type]/index.vue +2 -1
  36. package/app/pages/admin/content/[type]/new.vue +2 -1
  37. package/app/pages/admin/home.vue +6 -4
  38. package/app/pages/admin/settings.vue +10 -5
  39. package/app/plugins/pluto-core-registrations.ts +10 -7
  40. package/app/plugins/pluto-i18n.ts +20 -0
  41. package/i18n/locales/en.json +137 -0
  42. package/i18n/locales/pt-BR.json +137 -0
  43. package/modules/pluto-i18n/index.ts +89 -0
  44. package/modules/pluto-i18n/locale-files.ts +187 -0
  45. package/package.json +2 -2
  46. package/server/utils/pluto-content-handlers.ts +30 -7
  47. package/server/utils/pluto-error.ts +27 -0
  48. package/shared/types/i18n.d.ts +10 -0
  49. package/shared/types/i18n.ts +14 -0
  50. package/shared/types/registry.ts +13 -0
  51. package/shared/utils/content-validate.ts +17 -7
@@ -0,0 +1,137 @@
1
+ {
2
+ "pluto": {
3
+ "app": {
4
+ "name": "Pluto Admin"
5
+ },
6
+ "common": {
7
+ "save": "Salvar",
8
+ "cancel": "Cancelar",
9
+ "delete": "Excluir",
10
+ "tryAgain": "Tentar novamente",
11
+ "close": "Fechar",
12
+ "loading": "Carregando...",
13
+ "remove": "Remover",
14
+ "edit": "Editar",
15
+ "refresh": "Atualizar",
16
+ "settings": "Configurações",
17
+ "create": "Criar",
18
+ "error": "Erro",
19
+ "switchToLightMode": "Mudar para o tema claro",
20
+ "switchToDarkMode": "Mudar para o tema escuro"
21
+ },
22
+ "nav": {
23
+ "home": "Início",
24
+ "settings": "Configurações"
25
+ },
26
+ "pages": {
27
+ "dashboard": "Painel",
28
+ "settings": "Configurações"
29
+ },
30
+ "palette": {
31
+ "goTo": "Ir para",
32
+ "placeholder": "Buscar páginas e ações…"
33
+ },
34
+ "content": {
35
+ "new": "Criar {label}",
36
+ "edit": "Editar {label}",
37
+ "all": "Ver {labelPlural}",
38
+ "createNew": "Criar {label}",
39
+ "typeNotFound": "Tipo de conteúdo não encontrado",
40
+ "typeNotFoundDescription": "Nenhum tipo de conteúdo chamado \"{type}\" está registrado.",
41
+ "removeConfirm": "Deseja mesmo remover este item?"
42
+ },
43
+ "form": {
44
+ "published": "Publicado",
45
+ "unpublished": "Não publicado",
46
+ "saved": "Salvo",
47
+ "updated": "{label}: alterações salvas.",
48
+ "saveError": "Ocorreu um erro ao salvar.",
49
+ "removeError": "Ocorreu um erro ao remover.",
50
+ "saveDraft": "Salvar rascunho",
51
+ "update": "Atualizar",
52
+ "publish": "Publicar",
53
+ "unpublish": "Despublicar",
54
+ "fixErrors": "Corrija os itens abaixo antes de salvar",
55
+ "removeTitle": "Remover {label}"
56
+ },
57
+ "list": {
58
+ "loading": "Carregando {labelPlural}…",
59
+ "loadFailed": "Não foi possível carregar {labelPlural}",
60
+ "loadFailedHint": "Verifique a conexão e tente novamente.",
61
+ "empty": "Ainda não há {labelPlural}",
62
+ "emptyCanWrite": "Use “Criar {label}” para adicionar o primeiro item.",
63
+ "emptyReadOnly": "Ainda não há nada aqui.",
64
+ "refreshFailed": "Não foi possível atualizar {labelPlural}",
65
+ "refreshFailedHint": "A lista abaixo pode estar desatualizada.",
66
+ "emptyTable": "Nenhum item ainda.",
67
+ "removeTitle": "Remover item",
68
+ "showing": "Mostrando {loaded} {labelPlural}",
69
+ "showingOf": "Mostrando {loaded} de {total} {labelPlural}",
70
+ "loadingMore": "Carregando mais…"
71
+ },
72
+ "fields": {
73
+ "noWidget": "Nenhum widget registrado para o tipo de campo \"{type}\".",
74
+ "noOptionsSource": "Nenhuma fonte de opções configurada para este campo de referência."
75
+ },
76
+ "dashboard": {
77
+ "noWidgets": "Ainda não há widgets registrados no painel. Uma camada pode adicionar um com",
78
+ "overview": {
79
+ "title": "Conteúdo",
80
+ "label": "Visão geral do conteúdo",
81
+ "loadFailed": "Não foi possível carregar as contagens de conteúdo",
82
+ "thisWeek": "esta semana",
83
+ "noneThisWeek": "nada novo esta semana",
84
+ "meter": "Publicação: {published} de {total} {labelPlural}",
85
+ "published": "publicados | publicado | publicados",
86
+ "drafts": "rascunhos | rascunho | rascunhos"
87
+ },
88
+ "activity": {
89
+ "title": "Atividade de conteúdo",
90
+ "subtitle": "Itens criados por semana, últimas {weeks} semanas",
91
+ "created": "criados | criado | criados",
92
+ "loadFailed": "Não foi possível carregar a atividade",
93
+ "unavailable": "Atividade indisponível",
94
+ "unavailableHint": "O backend de conteúdo ainda não consegue contar itens pela data de criação.",
95
+ "none": "Nada criado nas últimas {weeks} semanas",
96
+ "noneHint": "Os novos itens aparecem aqui semana a semana.",
97
+ "weekOf": "Semana de"
98
+ },
99
+ "quickActions": {
100
+ "title": "Ações rápidas",
101
+ "pressBefore": "Pressione",
102
+ "pressAfter": "para buscar tudo."
103
+ }
104
+ },
105
+ "settings": {
106
+ "noStorage": "Sem armazenamento de configurações",
107
+ "noStorageHint": "Nenhuma camada instalada oferece armazenamento de configurações ainda.",
108
+ "language": {
109
+ "cardTitle": "Suas preferências",
110
+ "label": "Idioma",
111
+ "description": "Escolha o idioma do painel. Vale só para a sua conta.",
112
+ "automatic": "Automático (navegador)",
113
+ "saved": "Idioma atualizado",
114
+ "saveFailed": "Não foi possível salvar o idioma",
115
+ "saveFailedDescription": "O idioma mudou apenas neste dispositivo. {reason}"
116
+ }
117
+ },
118
+ "errors": {
119
+ "unknown": "Algo deu errado.",
120
+ "contentTypeNotFound": "Tipo de conteúdo \"{name}\" não encontrado.",
121
+ "noContentAdapter": "Nenhum adaptador de conteúdo está registrado.",
122
+ "notFound": "Não encontrado.",
123
+ "bodyNotObject": "O corpo da requisição deve ser um objeto JSON.",
124
+ "validationFailed": "Falha na validação."
125
+ },
126
+ "validation": {
127
+ "required": "Preencha \"{label}\".",
128
+ "number": "Informe um número em \"{label}\".",
129
+ "integer": "Informe um número inteiro em \"{label}\".",
130
+ "min": "Informe em \"{label}\" um valor de no mínimo {min}.",
131
+ "max": "Informe em \"{label}\" um valor de no máximo {max}.",
132
+ "boolean": "Use apenas verdadeiro ou falso em \"{label}\".",
133
+ "option": "Escolha uma das opções permitidas em \"{label}\".",
134
+ "maxLength": "Use no máximo {max} caracteres em \"{label}\"."
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,89 @@
1
+ import type { PlutoI18nOptions } from './locale-files'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import { addTemplate, defineNuxtModule, getLayerDirectories, resolvePath, updateTemplates, useLogger } from '@nuxt/kit'
5
+ import {
6
+ findLayerLocaleFiles,
7
+ LOCALE_DIR,
8
+ renderI18nTemplate,
9
+ renderI18nTypes,
10
+ resolveI18nOptions,
11
+ } from './locale-files'
12
+
13
+ export type { PlutoI18nOptions } from './locale-files'
14
+
15
+ /**
16
+ * Finds every layer's `i18n/locales/<code>.json` and generates
17
+ * `#build/pluto-i18n.mjs`: the locale codes and one lazy loader per locale.
18
+ * The files of all layers merge, base layer first and the app last, so the
19
+ * app overrides a layer's text. Adding a language is adding the JSON files.
20
+ * No code change.
21
+ *
22
+ * The module also puts the resolved options in
23
+ * `runtimeConfig.public.plutoI18n`. See the i18n skill.
24
+ */
25
+ export default defineNuxtModule<PlutoI18nOptions>({
26
+ meta: { name: 'pluto-i18n', configKey: 'plutoI18n' },
27
+ defaults: {},
28
+ async setup(options, nuxt) {
29
+ const logger = useLogger('pluto-i18n')
30
+ const layerRoots = getLayerDirectories(nuxt).map((layer) => layer.root)
31
+
32
+ function listDir(dir: string): string[] {
33
+ return existsSync(dir) ? readdirSync(dir) : []
34
+ }
35
+
36
+ // Nuxt UI's locale files. The generated file imports them by absolute
37
+ // path, so the app needs no direct `@nuxt/ui` dependency.
38
+ const uiLocaleDir = dirname(await resolvePath('@nuxt/ui/runtime/locale/en.js'))
39
+ const uiLocaleFiles = listDir(uiLocaleDir)
40
+ .filter((name) => name.endsWith('.js'))
41
+ .map((name) => name.slice(0, -'.js'.length))
42
+ .filter((name) => name !== 'index')
43
+
44
+ let files = findLayerLocaleFiles(layerRoots, listDir)
45
+ const resolved = resolveI18nOptions(options, Object.keys(files))
46
+
47
+ if (!nuxt.options._prepare) {
48
+ for (const warning of resolved.warnings) {
49
+ logger.warn(warning)
50
+ }
51
+ }
52
+
53
+ nuxt.options.runtimeConfig.public.plutoI18n = {
54
+ defaultLocale: resolved.defaultLocale,
55
+ adminLocales: resolved.adminLocales,
56
+ siteLocales: resolved.siteLocales,
57
+ siteLocale: resolved.siteLocale,
58
+ }
59
+
60
+ addTemplate({
61
+ filename: 'pluto-i18n.mjs',
62
+ write: true,
63
+ getContents: () => renderI18nTemplate({
64
+ files,
65
+ defaultLocale: resolved.defaultLocale,
66
+ uiLocaleDir,
67
+ uiLocaleFiles,
68
+ }),
69
+ })
70
+
71
+ addTemplate({
72
+ filename: 'pluto-i18n.d.mts',
73
+ write: true,
74
+ getContents: renderI18nTypes,
75
+ })
76
+
77
+ // A new or removed locale file changes the generated file. Locale
78
+ // codes and options come from the first run, so restart the dev server
79
+ // after a new language. An edit of an existing file hot-reloads on its own.
80
+ nuxt.hook('builder:watch', async (_event, path) => {
81
+ const normalized = path.replaceAll('\\', '/')
82
+ if (!normalized.includes(`/${LOCALE_DIR}/`) || !normalized.endsWith('.json')) {
83
+ return
84
+ }
85
+ files = findLayerLocaleFiles(layerRoots, listDir)
86
+ await updateTemplates({ filter: (template) => template.filename.startsWith('pluto-i18n.') })
87
+ })
88
+ },
89
+ })
@@ -0,0 +1,187 @@
1
+ // Pure helpers for the pluto-i18n module. They do not import `nuxt/kit`, so
2
+ // the unit tests can call them directly.
3
+ import { join } from 'node:path'
4
+
5
+ // One source for the matcher: the runtime plugin uses it too.
6
+ export { matchLocale } from '../../app/composables/internal/i18n-core'
7
+
8
+ /** Where a layer (or the app) keeps its locale files, under its root dir. */
9
+ export const LOCALE_DIR = 'i18n/locales'
10
+
11
+ /** A valid locale code: `en`, `pt-BR`. A language, and an optional region. */
12
+ const LOCALE_CODE = /^[a-z]{2,3}(?:-[A-Z]{2})?$/
13
+
14
+ export function isLocaleCode(value: string): boolean {
15
+ return LOCALE_CODE.test(value)
16
+ }
17
+
18
+ function toPosix(path: string): string {
19
+ return path.replaceAll('\\', '/')
20
+ }
21
+
22
+ /**
23
+ * Returns the locale files of every layer, grouped by locale code. Each list
24
+ * has the base layer first and the app last, so a later file overrides an
25
+ * earlier one. Nuxt lists the layers top first (the app, then each layer it
26
+ * extends), so the list is reversed. `listDir` returns the file names in a
27
+ * directory, or an empty list if the directory is missing. A file whose name
28
+ * is not `<code>.json` with a valid code is ignored.
29
+ */
30
+ export function findLayerLocaleFiles(
31
+ layerRoots: string[],
32
+ listDir: (dir: string) => string[]
33
+ ): Record<string, string[]> {
34
+ const files: Record<string, string[]> = {}
35
+ for (const root of [...layerRoots].reverse()) {
36
+ const dir = join(root, LOCALE_DIR)
37
+ for (const name of listDir(dir).toSorted()) {
38
+ if (!name.endsWith('.json')) {
39
+ continue
40
+ }
41
+ const code = name.slice(0, -'.json'.length)
42
+ if (isLocaleCode(code)) {
43
+ (files[code] ??= []).push(toPosix(join(dir, name)))
44
+ }
45
+ }
46
+ }
47
+ return files
48
+ }
49
+
50
+ /**
51
+ * Returns the Nuxt UI locale file name (without extension) for a locale
52
+ * code. `available` lists the file names in Nuxt UI's locale directory, such
53
+ * as `en` and `pt_br`. The order: the full code (`pt-BR` gives `pt_br`), the
54
+ * base language (`pt`), then `en`.
55
+ */
56
+ export function resolveNuxtUiLocaleFile(code: string, available: string[]): string {
57
+ const full = code.toLowerCase().replaceAll('-', '_')
58
+ if (available.includes(full)) {
59
+ return full
60
+ }
61
+ const base = full.split('_')[0]!
62
+ if (available.includes(base)) {
63
+ return base
64
+ }
65
+ return 'en'
66
+ }
67
+
68
+ export interface I18nTemplateInput {
69
+ /** Locale files by code, from `findLayerLocaleFiles`. */
70
+ files: Record<string, string[]>
71
+ defaultLocale: string
72
+ /** Absolute directory of Nuxt UI's locale files. */
73
+ uiLocaleDir: string
74
+ /** The file names in `uiLocaleDir`, without extension. */
75
+ uiLocaleFiles: string[]
76
+ }
77
+
78
+ /** Renders the source of `#build/pluto-i18n.mjs`. */
79
+ export function renderI18nTemplate(input: I18nTemplateInput): string {
80
+ const codes = Object.keys(input.files).toSorted()
81
+ const messageLoaders = codes.map((code) => {
82
+ const imports = input.files[code]!.map((file) => `import(${JSON.stringify(file)})`).join(', ')
83
+ return ` ${JSON.stringify(code)}: async () => merge(...(await Promise.all([${imports}])).map((m) => m.default ?? m)),`
84
+ })
85
+ const uiLocaleLoaders = codes.map((code) => {
86
+ const file = resolveNuxtUiLocaleFile(code, input.uiLocaleFiles)
87
+ const path = `${toPosix(input.uiLocaleDir).replace(/\/$/, '')}/${file}.js`
88
+ return ` ${JSON.stringify(code)}: () => import(${JSON.stringify(path)}).then((m) => m.default),`
89
+ })
90
+ return [
91
+ '// Generated by @plutocms/pluto (modules/pluto-i18n). Do not edit.',
92
+ '// Each layer\'s i18n/locales/<code>.json, base layer first, the app last.',
93
+ `export const localeCodes = ${JSON.stringify(codes)}`,
94
+ `export const defaultLocale = ${JSON.stringify(input.defaultLocale)}`,
95
+ '',
96
+ 'function isTree(value) {',
97
+ ' return typeof value === \'object\' && value !== null',
98
+ '}',
99
+ '',
100
+ 'function merge(...trees) {',
101
+ ' const result = {}',
102
+ ' for (const tree of trees) {',
103
+ ' for (const [key, value] of Object.entries(tree)) {',
104
+ ' result[key] = isTree(value) ? merge(isTree(result[key]) ? result[key] : {}, value) : value',
105
+ ' }',
106
+ ' }',
107
+ ' return result',
108
+ '}',
109
+ '',
110
+ 'export const messageLoaders = {',
111
+ ...messageLoaders,
112
+ '}',
113
+ '',
114
+ 'export const uiLocaleLoaders = {',
115
+ ...uiLocaleLoaders,
116
+ '}',
117
+ '',
118
+ ].join('\n')
119
+ }
120
+
121
+ /** Renders the type declaration of `#build/pluto-i18n.mjs`. */
122
+ export function renderI18nTypes(): string {
123
+ return [
124
+ '// Generated by @plutocms/pluto (modules/pluto-i18n). Do not edit.',
125
+ 'export declare const localeCodes: string[]',
126
+ 'export declare const defaultLocale: string',
127
+ 'export declare const messageLoaders: Record<string, () => Promise<Record<string, unknown>>>',
128
+ 'export declare const uiLocaleLoaders: Record<string, () => Promise<any>>',
129
+ '',
130
+ ].join('\n')
131
+ }
132
+
133
+ export interface PlutoI18nOptions {
134
+ /** The fallback locale, and the last choice of every resolve order. Default `en`. */
135
+ defaultLocale?: string
136
+ /** Locales the admin offers. Default: every discovered locale. */
137
+ adminLocales?: string[]
138
+ /** Locales the public site can use. Default: every discovered locale. */
139
+ siteLocales?: string[]
140
+ /** The public site's locale. Default: `defaultLocale`. */
141
+ siteLocale?: string
142
+ }
143
+
144
+ export interface ResolvedI18nOptions {
145
+ defaultLocale: string
146
+ adminLocales: string[]
147
+ siteLocales: string[]
148
+ siteLocale: string
149
+ warnings: string[]
150
+ }
151
+
152
+ /**
153
+ * Fills the module options from the discovered locale codes. A locale an
154
+ * option names but no file provides is dropped, with a warning.
155
+ */
156
+ export function resolveI18nOptions(options: PlutoI18nOptions, discovered: string[]): ResolvedI18nOptions {
157
+ const warnings: string[] = []
158
+ const known = new Set(discovered)
159
+
160
+ function pick(name: string, wanted: string[] | undefined): string[] {
161
+ if (!wanted) {
162
+ return [...discovered]
163
+ }
164
+ const kept = wanted.filter((code) => known.has(code))
165
+ for (const code of wanted.filter((code) => !known.has(code))) {
166
+ warnings.push(`plutoI18n.${name} lists "${code}", but no layer has i18n/locales/${code}.json. Dropped.`)
167
+ }
168
+ return kept
169
+ }
170
+
171
+ let defaultLocale = options.defaultLocale ?? 'en'
172
+ if (!known.has(defaultLocale)) {
173
+ warnings.push(`plutoI18n.defaultLocale "${defaultLocale}" has no locale file. Using "en".`)
174
+ defaultLocale = 'en'
175
+ }
176
+
177
+ const adminLocales = pick('adminLocales', options.adminLocales)
178
+ const siteLocales = pick('siteLocales', options.siteLocales)
179
+
180
+ let siteLocale = options.siteLocale ?? defaultLocale
181
+ if (!siteLocales.includes(siteLocale)) {
182
+ warnings.push(`plutoI18n.siteLocale "${siteLocale}" is not in siteLocales. Using "${defaultLocale}".`)
183
+ siteLocale = defaultLocale
184
+ }
185
+
186
+ return { defaultLocale, adminLocales, siteLocales, siteLocale, warnings }
187
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.13.0",
4
+ "version": "0.14.1",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "postinstall": "nuxt prepare",
30
30
  "prettier": "prettier --write .",
31
31
  "lint": "eslint .",
32
- "lint:fix": "eslint . --fix && prettier --write .",
32
+ "lint:fix": "eslint . --fix",
33
33
  "typecheck": "nuxt prepare && vue-tsc -b --noEmit",
34
34
  "test": "vitest run"
35
35
  },
@@ -1,7 +1,7 @@
1
1
  import type { H3Event } from 'h3'
2
- import { createError } from 'h3'
3
2
  import { validateContentPayload } from '../../shared/utils/content-validate'
4
3
  import { getContentAdapter, getContentType } from './pluto-content'
4
+ import { createPlutoError } from './pluto-error'
5
5
 
6
6
  /**
7
7
  * The real logic behind the generic content routes, kept in plain exported
@@ -14,7 +14,12 @@ export function requireContentType(name: string): PlutoContentType {
14
14
  const type = getContentType(name)
15
15
 
16
16
  if (!type) {
17
- throw createError({ statusCode: 404, statusMessage: `Content type "${name}" not found.` })
17
+ throw createPlutoError({
18
+ statusCode: 404,
19
+ message: `Content type "${name}" not found.`,
20
+ key: 'pluto.errors.contentTypeNotFound',
21
+ params: { name },
22
+ })
18
23
  }
19
24
 
20
25
  return type
@@ -24,7 +29,11 @@ export function requireContentAdapter(): PlutoContentAdapter {
24
29
  const adapter = getContentAdapter()
25
30
 
26
31
  if (!adapter) {
27
- throw createError({ statusCode: 500, statusMessage: 'No content adapter is registered.' })
32
+ throw createPlutoError({
33
+ statusCode: 500,
34
+ message: 'No content adapter is registered.',
35
+ key: 'pluto.errors.noContentAdapter',
36
+ })
28
37
  }
29
38
 
30
39
  return adapter
@@ -97,7 +106,7 @@ export async function getContentItem(ctx: PlutoContentContext, idOrSlug: string
97
106
  const item = await requireContentAdapter().get(ctx, idOrSlug)
98
107
 
99
108
  if (!item) {
100
- throw createError({ statusCode: 404, statusMessage: 'Not found.' })
109
+ throw createPlutoError({ statusCode: 404, message: 'Not found.', key: 'pluto.errors.notFound' })
101
110
  }
102
111
 
103
112
  return item
@@ -105,7 +114,11 @@ export async function getContentItem(ctx: PlutoContentContext, idOrSlug: string
105
114
 
106
115
  function requirePlainObjectBody(body: unknown): Record<string, unknown> {
107
116
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
108
- throw createError({ statusCode: 400, statusMessage: 'Request body must be a JSON object.' })
117
+ throw createPlutoError({
118
+ statusCode: 400,
119
+ message: 'Request body must be a JSON object.',
120
+ key: 'pluto.errors.bodyNotObject',
121
+ })
109
122
  }
110
123
 
111
124
  return body as Record<string, unknown>
@@ -122,7 +135,12 @@ export async function createContentItem(ctx: PlutoContentContext, body: unknown)
122
135
 
123
136
  const errors = validateContentPayload(ctx.type, payload)
124
137
  if (errors.length > 0) {
125
- throw createError({ statusCode: 400, statusMessage: 'Validation failed.', data: { errors } })
138
+ throw createPlutoError({
139
+ statusCode: 400,
140
+ message: 'Validation failed.',
141
+ key: 'pluto.errors.validationFailed',
142
+ data: { errors },
143
+ })
126
144
  }
127
145
 
128
146
  await authorizeContentOperation(ctx.event, ctx.type.capabilities?.write)
@@ -151,7 +169,12 @@ export async function updateContentItem(
151
169
 
152
170
  const errors = validateContentPayload(ctx.type, payload, { partial: true })
153
171
  if (errors.length > 0) {
154
- throw createError({ statusCode: 400, statusMessage: 'Validation failed.', data: { errors } })
172
+ throw createPlutoError({
173
+ statusCode: 400,
174
+ message: 'Validation failed.',
175
+ key: 'pluto.errors.validationFailed',
176
+ data: { errors },
177
+ })
155
178
  }
156
179
 
157
180
  await authorizeContentOperation(ctx.event, ctx.type.capabilities?.write)
@@ -0,0 +1,27 @@
1
+ import type { H3Error } from 'h3'
2
+ import { createError } from 'h3'
3
+
4
+ export interface PlutoErrorOptions {
5
+ statusCode: number
6
+ /** English text. It becomes the `statusMessage`, so the server always speaks English. */
7
+ message: string
8
+ /** Translation key of the same message, for the admin UI. Example: `pluto.errors.notFound`. */
9
+ key: string
10
+ /** Values for the translated message. */
11
+ params?: Record<string, unknown>
12
+ /** Extra response data, for example `{ errors }`. It sits next to `i18n`. */
13
+ data?: Record<string, unknown>
14
+ }
15
+
16
+ /**
17
+ * Creates an H3 error that the admin can show in its own language. The
18
+ * `statusMessage` stays English. The translation key and its values go in
19
+ * `data.i18n`, and the client reads them with `usePlutoErrorText()`.
20
+ */
21
+ export function createPlutoError(options: PlutoErrorOptions): H3Error {
22
+ return createError({
23
+ statusCode: options.statusCode,
24
+ statusMessage: options.message,
25
+ data: { ...options.data, i18n: { key: options.key, params: options.params } },
26
+ })
27
+ }
@@ -0,0 +1,10 @@
1
+ import type { PlutoI18nRuntimeConfig } from './i18n'
2
+
3
+ // The `pluto-i18n` module fills this key. See modules/pluto-i18n/index.ts.
4
+ declare module '@nuxt/schema' {
5
+ interface PublicRuntimeConfig {
6
+ plutoI18n: PlutoI18nRuntimeConfig
7
+ }
8
+ }
9
+
10
+ export {}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The options of the `pluto-i18n` module, as the module resolves them and
3
+ * exposes them in `runtimeConfig.public.plutoI18n`.
4
+ */
5
+ export interface PlutoI18nRuntimeConfig {
6
+ /** The fallback locale. */
7
+ defaultLocale: string
8
+ /** Locales the admin offers. */
9
+ adminLocales: string[]
10
+ /** Locales the public site can use. */
11
+ siteLocales: string[]
12
+ /** The public site's locale before a visitor picks one. */
13
+ siteLocale: string
14
+ }
@@ -61,6 +61,17 @@ export interface PlutoSettingsDriver extends PlutoEntry {
61
61
  save: (patch: Record<string, string>) => Promise<void>
62
62
  }
63
63
 
64
+ /**
65
+ * Loads and saves the current user's admin language. One backend layer
66
+ * registers this, the same way one layer registers a PlutoSettingsDriver.
67
+ * `load` returns the stored locale code, or null when the user chose
68
+ * Automatic (browser language). `save` stores a code, or null for Automatic.
69
+ */
70
+ export interface PlutoLocaleDriver extends PlutoEntry {
71
+ load: () => Promise<string | null>
72
+ save: (locale: string | null) => Promise<void>
73
+ }
74
+
64
75
  /**
65
76
  * A capability a layer defines. `key` is the real capability string, for
66
77
  * example 'posts:publish'. It is kept separate from `id` on purpose: the
@@ -151,6 +162,7 @@ export interface PlutoExtension {
151
162
  dashboardWidgets?: PlutoDashboardWidget[]
152
163
  settingsPanels?: PlutoSettingsPanel[]
153
164
  settingsDriver?: PlutoSettingsDriver
165
+ localeDriver?: PlutoLocaleDriver
154
166
  capabilities?: PlutoCapability[]
155
167
  permissionsDriver?: PlutoPermissionsDriver
156
168
  contentTypes?: PlutoContentTypeEntry[]
@@ -178,6 +190,7 @@ export interface PlutoRegistry {
178
190
  pages: PlutoRegistryBucket<PlutoAdminPage>
179
191
  settingsPanels: PlutoRegistryBucket<PlutoSettingsPanel>
180
192
  settingsDrivers: PlutoRegistryBucket<PlutoSettingsDriver>
193
+ localeDrivers: PlutoRegistryBucket<PlutoLocaleDriver>
181
194
  dashboardWidgets: PlutoRegistryBucket<PlutoDashboardWidget>
182
195
  capabilities: PlutoRegistryBucket<PlutoCapability>
183
196
  permissionsDrivers: PlutoRegistryBucket<PlutoPermissionsDriver>
@@ -1,8 +1,16 @@
1
1
  import type { PlutoContentType } from '../types/content'
2
2
 
3
+ /** Why a field failed. Each code has a `pluto.validation.<code>` message key. */
4
+ export type ContentValidationCode = 'required' | 'number' | 'integer' | 'min' | 'max' | 'boolean' | 'option' | 'maxLength'
5
+
3
6
  export interface ContentValidationError {
4
7
  field: string
8
+ /** English text. It never changes with the admin language. */
5
9
  message: string
10
+ /** Machine-readable reason. The admin form uses it to show a translated message. */
11
+ code?: ContentValidationCode
12
+ /** Values for the translated message, for example `{ min: 1 }`. */
13
+ params?: Record<string, unknown>
6
14
  }
7
15
 
8
16
  /**
@@ -32,7 +40,7 @@ export function validateContentPayload(
32
40
  const skipBecausePartial = options?.partial === true && !present
33
41
  const empty = value === undefined || value === null || value === ''
34
42
  if (!skipBecausePartial && empty) {
35
- errors.push({ field: field.name, message: `"${field.label}" is required.` })
43
+ errors.push({ field: field.name, message: `"${field.label}" is required.`, code: 'required' })
36
44
  continue
37
45
  }
38
46
  }
@@ -46,31 +54,31 @@ export function validateContentPayload(
46
54
  case 'number': {
47
55
  const numeric = typeof value === 'number' ? value : Number(value)
48
56
  if ((typeof value !== 'number' && typeof value !== 'string') || Number.isNaN(numeric)) {
49
- errors.push({ field: field.name, message: `"${field.label}" must be a number.` })
57
+ errors.push({ field: field.name, message: `"${field.label}" must be a number.`, code: 'number' })
50
58
  break
51
59
  }
52
60
  if (field.integer && !Number.isInteger(numeric)) {
53
- errors.push({ field: field.name, message: `"${field.label}" must be an integer.` })
61
+ errors.push({ field: field.name, message: `"${field.label}" must be an integer.`, code: 'integer' })
54
62
  }
55
63
  if (field.min !== undefined && numeric < field.min) {
56
- errors.push({ field: field.name, message: `"${field.label}" must be at least ${field.min}.` })
64
+ errors.push({ field: field.name, message: `"${field.label}" must be at least ${field.min}.`, code: 'min', params: { min: field.min } })
57
65
  }
58
66
  if (field.max !== undefined && numeric > field.max) {
59
- errors.push({ field: field.name, message: `"${field.label}" must be at most ${field.max}.` })
67
+ errors.push({ field: field.name, message: `"${field.label}" must be at most ${field.max}.`, code: 'max', params: { max: field.max } })
60
68
  }
61
69
  break
62
70
  }
63
71
 
64
72
  case 'boolean': {
65
73
  if (typeof value !== 'boolean') {
66
- errors.push({ field: field.name, message: `"${field.label}" must be a boolean.` })
74
+ errors.push({ field: field.name, message: `"${field.label}" must be a boolean.`, code: 'boolean' })
67
75
  }
68
76
  break
69
77
  }
70
78
 
71
79
  case 'select': {
72
80
  if (!field.options.some((option) => option.value === value)) {
73
- errors.push({ field: field.name, message: `"${field.label}" must be one of the allowed options.` })
81
+ errors.push({ field: field.name, message: `"${field.label}" must be one of the allowed options.`, code: 'option' })
74
82
  }
75
83
  break
76
84
  }
@@ -86,6 +94,8 @@ export function validateContentPayload(
86
94
  errors.push({
87
95
  field: field.name,
88
96
  message: `"${field.label}" must be at most ${maxLength} characters.`,
97
+ code: 'maxLength',
98
+ params: { max: maxLength },
89
99
  })
90
100
  }
91
101
  break