@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
@@ -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.12.0",
4
+ "version": "0.14.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -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
- 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': n(900), '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' }
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: #fff;
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
- /* Cards (--ui-bg, zinc-900) sit one step above the canvas (zinc-950). */
80
- --pluto-admin-canvas: ${v('zinc-950')};
81
- --pluto-admin-sidebar: ${v('zinc-950')};
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 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