@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,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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,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
|
+
}
|
package/shared/types/registry.ts
CHANGED
|
@@ -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
|