@plutocms/pluto 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/FEATURES.md +7 -0
- package/app/app.vue +5 -2
- package/app/assets/css/admin-theme.css +16 -7
- package/app/components/ColorModeButton.vue +2 -1
- package/app/components/Pluto/Content/PlutoContentField.vue +17 -3
- package/app/components/Pluto/Content/PlutoContentForm.vue +60 -38
- package/app/components/Pluto/Content/PlutoContentList.vue +84 -42
- package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +2 -1
- package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +22 -19
- package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +15 -14
- package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +6 -5
- package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +4 -1
- package/app/components/Pluto/PlutoDashboardWidgets.vue +2 -1
- package/app/components/Pluto/PlutoRoot.vue +18 -1
- package/app/components/Pluto/Settings/PlutoSettingsLanguage.vue +51 -0
- package/app/components/Pluto/View/PlutoViewAdminPanel.vue +9 -1
- package/app/components/Pluto/View/PlutoViewAside.vue +14 -7
- package/app/components/modal/Modal.vue +1 -1
- package/app/components/modal/ModalHeader.vue +3 -1
- package/app/composables/internal/content-text.ts +111 -0
- package/app/composables/internal/error-text.ts +58 -0
- package/app/composables/internal/i18n-core.ts +310 -0
- package/app/composables/internal/i18n-runtime.ts +87 -0
- package/app/composables/pluto-admin-nav.ts +21 -4
- package/app/composables/pluto-admin-pages.ts +13 -3
- package/app/composables/pluto-command-palette.ts +13 -6
- package/app/composables/pluto-content-actions.ts +9 -5
- package/app/composables/pluto-content-list.ts +88 -7
- package/app/composables/pluto-content-text.ts +28 -0
- package/app/composables/pluto-dashboard.ts +15 -2
- package/app/composables/pluto-error.ts +13 -0
- package/app/composables/pluto-extension.ts +4 -0
- package/app/composables/pluto-i18n.ts +171 -0
- package/app/composables/pluto-permissions.ts +16 -2
- package/app/composables/pluto-registry.ts +1 -0
- package/app/composables/pluto-settings.ts +16 -2
- package/app/layouts/admin.vue +2 -1
- package/app/pages/admin/content/[type]/[id].vue +3 -1
- package/app/pages/admin/content/[type]/index.vue +2 -1
- package/app/pages/admin/content/[type]/new.vue +2 -1
- package/app/pages/admin/home.vue +6 -4
- package/app/pages/admin/settings.vue +10 -5
- package/app/plugins/pluto-core-registrations.ts +10 -7
- package/app/plugins/pluto-i18n.ts +20 -0
- package/i18n/locales/en.json +137 -0
- package/i18n/locales/pt-BR.json +137 -0
- package/modules/pluto-i18n/index.ts +89 -0
- package/modules/pluto-i18n/locale-files.ts +187 -0
- package/package.json +1 -1
- package/scripts/generate-admin-theme.cjs +21 -7
- package/server/utils/pluto-content-handlers.ts +30 -7
- package/server/utils/pluto-error.ts +27 -0
- package/shared/types/i18n.d.ts +10 -0
- package/shared/types/i18n.ts +14 -0
- package/shared/types/registry.ts +13 -0
- package/shared/utils/content-validate.ts +17 -7
|
@@ -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
|
@@ -13,7 +13,12 @@ const scales = Object.entries(roles).map(([role, hue]) => ` /* ${role}: ${hue}
|
|
|
13
13
|
const semantic = (shade) => Object.keys(roles).filter((r) => r !== 'neutral').map((r) => ` --ui-${r}: var(--ui-color-${r}-${shade});`).join('\n')
|
|
14
14
|
const n = (s) => `var(--ui-color-neutral-${s})`
|
|
15
15
|
const neutralLight = { 'text-dimmed': n(400), 'text-muted': n(500), 'text-toned': n(600), 'text': n(700), 'text-highlighted': n(900), 'text-inverted': '#fff', 'bg': '#fff', 'bg-muted': n(50), 'bg-elevated': n(100), 'bg-accented': n(200), 'bg-inverted': n(900), 'border': n(200), 'border-muted': n(200), 'border-accented': n(300), 'border-inverted': n(900) }
|
|
16
|
-
|
|
16
|
+
// Dark cards sit one step above the zinc-900 canvas: between zinc-900 and
|
|
17
|
+
// zinc-800. The chart palette passes the dataviz checks on this surface.
|
|
18
|
+
const CARD_DARK = 'oklch(24% 0.006 286)'
|
|
19
|
+
// The dark sidebar sits between the zinc-950 navbar and the zinc-900 canvas.
|
|
20
|
+
const SIDEBAR_DARK = 'oklch(17.5% 0.0055 286)'
|
|
21
|
+
const neutralDark = { 'text-dimmed': n(500), 'text-muted': n(400), 'text-toned': n(300), 'text': n(200), 'text-highlighted': '#fff', 'text-inverted': n(900), 'bg': CARD_DARK, 'bg-muted': n(800), 'bg-elevated': n(800), 'bg-accented': n(700), 'bg-inverted': '#fff', 'border': n(800), 'border-muted': n(700), 'border-accented': n(700), 'border-inverted': '#fff' }
|
|
17
22
|
const tokens = (o) => Object.entries(o).map(([k, val]) => ` --ui-${k}: ${val};`).join('\n')
|
|
18
23
|
// Validated with the dataviz palette checks on white (light) and zinc-900
|
|
19
24
|
// (dark). Re-run the checks before you change a slot.
|
|
@@ -32,6 +37,10 @@ const css = `/*
|
|
|
32
37
|
* - \`[data-pluto-admin-scope]\`: for admin chrome that also shows on the
|
|
33
38
|
* public site, such as the admin navbar for a logged-in admin.
|
|
34
39
|
*
|
|
40
|
+
* \`[data-pluto-admin-dark]\` takes the dark tokens in both color modes. The
|
|
41
|
+
* admin navbar is near black in light mode too, so its text and buttons need
|
|
42
|
+
* the dark values.
|
|
43
|
+
*
|
|
35
44
|
* Every value is a literal. The consumer app styles its public site through
|
|
36
45
|
* its own app.config colors and @theme. Both reach the admin unless the admin
|
|
37
46
|
* pins its own values: app.config colors set Nuxt UI's --ui-color-* scales on
|
|
@@ -59,27 +68,32 @@ ${tokens(neutralLight)}
|
|
|
59
68
|
|
|
60
69
|
${chart(chartLight)}
|
|
61
70
|
|
|
62
|
-
/* Surfaces */
|
|
71
|
+
/* Surfaces. The navbar is near black in both modes. */
|
|
63
72
|
--pluto-admin-canvas: #fff;
|
|
64
73
|
--pluto-admin-sidebar: ${v('zinc-50')};
|
|
65
|
-
--pluto-admin-navbar:
|
|
74
|
+
--pluto-admin-navbar: ${v('zinc-950')};
|
|
66
75
|
|
|
67
76
|
--ui-radius: 0.375rem;
|
|
68
77
|
--font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
:root[data-pluto-admin].dark,
|
|
72
|
-
.dark [data-pluto-admin-scope]
|
|
81
|
+
.dark [data-pluto-admin-scope],
|
|
82
|
+
[data-pluto-admin-dark] {
|
|
73
83
|
${semantic(400)}
|
|
74
84
|
|
|
75
85
|
${tokens(neutralDark)}
|
|
76
86
|
|
|
77
87
|
${chart(chartDark)}
|
|
78
88
|
|
|
79
|
-
/*
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
/*
|
|
90
|
+
* Four steps, darkest at the top: a near-black navbar, a darker sidebar,
|
|
91
|
+
* a dim dark gray canvas (not pitch black), and cards (--ui-bg) one step
|
|
92
|
+
* above the canvas.
|
|
93
|
+
*/
|
|
82
94
|
--pluto-admin-navbar: ${v('zinc-950')};
|
|
95
|
+
--pluto-admin-sidebar: ${SIDEBAR_DARK};
|
|
96
|
+
--pluto-admin-canvas: ${v('zinc-900')};
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
:root[data-pluto-admin] body {
|
|
@@ -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
|