@cat-factory/app 0.41.0 → 0.42.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/app/composables/usePipelineErrorToast.spec.ts +100 -0
- package/app/composables/usePipelineErrorToast.ts +51 -26
- package/app/stores/agentRuns.ts +1 -1
- package/app/stores/execution.ts +3 -3
- package/i18n/i18n.config.ts +29 -0
- package/i18n/locales/en.json +35 -0
- package/nuxt.config.ts +23 -1
- package/package.json +4 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { usePipelineErrorToast, parseConflict } from '~/composables/usePipelineErrorToast'
|
|
3
|
+
import { ApiError } from '~/composables/api/errors'
|
|
4
|
+
import en from '../../i18n/locales/en.json'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The i18n pilot: the pipeline-error toast resolves user-facing copy from
|
|
8
|
+
* `errors.conflict.*` message KEYS by the backend's machine-readable `reason`, and only
|
|
9
|
+
* ever shows raw backend prose as a last-resort description. These specs assert the KEYS
|
|
10
|
+
* and params a code path resolves (never the English text), so they stay locale-agnostic.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Dot-path lookup into the real `en.json`, so `te` mirrors which keys actually ship. */
|
|
14
|
+
function hasKey(path: string): boolean {
|
|
15
|
+
return (
|
|
16
|
+
path.split('.').reduce<unknown>((node, seg) => {
|
|
17
|
+
return node && typeof node === 'object' ? (node as Record<string, unknown>)[seg] : undefined
|
|
18
|
+
}, en) !== undefined
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let add: ReturnType<typeof vi.fn>
|
|
23
|
+
let t: ReturnType<typeof vi.fn>
|
|
24
|
+
let openAiProviderSetup: ReturnType<typeof vi.fn>
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
add = vi.fn()
|
|
28
|
+
// `t` echoes the key so the toast's title/description IS the resolved key — assert on it.
|
|
29
|
+
t = vi.fn((key: string) => key)
|
|
30
|
+
openAiProviderSetup = vi.fn()
|
|
31
|
+
vi.stubGlobal('useToast', () => ({ add }))
|
|
32
|
+
vi.stubGlobal('useUiStore', () => ({ openAiProviderSetup }))
|
|
33
|
+
vi.stubGlobal('useI18n', () => ({ t, te: (key: string) => hasKey(key) }))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
function conflict(reason?: string, details: Record<string, unknown> = {}, message?: string) {
|
|
37
|
+
return new ApiError(409, {
|
|
38
|
+
error: { code: 'conflict', message, details: { reason, ...details } },
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('parseConflict', () => {
|
|
43
|
+
it('extracts reason + raw message + details from a 409 conflict', () => {
|
|
44
|
+
const parsed = parseConflict(conflict('dependencies_unmet', { foo: 1 }, 'raw msg'))
|
|
45
|
+
expect(parsed).toEqual({
|
|
46
|
+
reason: 'dependencies_unmet',
|
|
47
|
+
message: 'raw msg',
|
|
48
|
+
details: { reason: 'dependencies_unmet', foo: 1 },
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns null for a non-conflict error', () => {
|
|
53
|
+
expect(parseConflict(new ApiError(500, { error: { code: 'internal' } }))).toBeNull()
|
|
54
|
+
expect(parseConflict(new Error('network'))).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('usePipelineErrorToast', () => {
|
|
59
|
+
it('titles a mapped conflict reason from its errors.conflict.title.<reason> key', () => {
|
|
60
|
+
usePipelineErrorToast().present(conflict('dependencies_unmet'))
|
|
61
|
+
expect(add).toHaveBeenCalledTimes(1)
|
|
62
|
+
expect(add.mock.calls[0]![0].title).toBe('errors.conflict.title.dependencies_unmet')
|
|
63
|
+
expect(t).toHaveBeenCalledWith('errors.conflict.title.dependencies_unmet')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('falls back to the caller fallback key when the reason has no dedicated title', () => {
|
|
67
|
+
usePipelineErrorToast().present(conflict('totally_unknown_reason'), 'errors.action.retryFailed')
|
|
68
|
+
expect(add.mock.calls[0]![0].title).toBe('errors.action.retryFailed')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('shows the raw backend message as the conflict description', () => {
|
|
72
|
+
usePipelineErrorToast().present(conflict('dependencies_unmet', {}, 'A depends on B'))
|
|
73
|
+
expect(add.mock.calls[0]![0].description).toBe('A depends on B')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('falls back to a translated description when the backend sends no message', () => {
|
|
77
|
+
usePipelineErrorToast().present(conflict('dependencies_unmet'))
|
|
78
|
+
expect(add.mock.calls[0]![0].description).toBe('errors.conflict.fallbackMessage')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('interpolates the model list for providers_unconfigured and offers the AI setup jump', () => {
|
|
82
|
+
usePipelineErrorToast().present(
|
|
83
|
+
conflict('providers_unconfigured', { models: ['gpt-x', 'claude-y'] }),
|
|
84
|
+
)
|
|
85
|
+
const arg = add.mock.calls[0]![0]
|
|
86
|
+
expect(arg.title).toBe('errors.conflict.providersUnconfigured.title')
|
|
87
|
+
expect(t).toHaveBeenCalledWith('errors.conflict.providersUnconfigured.body', {
|
|
88
|
+
models: 'gpt-x, claude-y',
|
|
89
|
+
})
|
|
90
|
+
arg.actions[0].onClick()
|
|
91
|
+
expect(openAiProviderSetup).toHaveBeenCalledOnce()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('uses the fallback title key + raw message for a non-conflict error', () => {
|
|
95
|
+
usePipelineErrorToast().present(new Error('boom'), 'errors.action.startFailed')
|
|
96
|
+
const arg = add.mock.calls[0]![0]
|
|
97
|
+
expect(arg.title).toBe('errors.action.startFailed')
|
|
98
|
+
expect(arg.description).toBe('boom')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -4,8 +4,15 @@
|
|
|
4
4
|
* `error.details.reason` (kernel `ConflictReason`), so we can word each case precisely
|
|
5
5
|
* instead of dumping the raw message — and, for `providers_unconfigured`, surface the
|
|
6
6
|
* SAME guidance + "Configure AI" jump as the no-AI-provider startup banner.
|
|
7
|
+
*
|
|
8
|
+
* i18n boundary (see CLAUDE.md / the i18n plan): user-facing titles are resolved from
|
|
9
|
+
* `errors.conflict.*` message keys by the machine-readable `reason`. The raw backend
|
|
10
|
+
* `message` is shown only as the description fallback and stays untranslated — the
|
|
11
|
+
* contract is "if a server message must be localizable, the backend emits a code and the
|
|
12
|
+
* frontend maps it", not "translate arbitrary server prose on the client".
|
|
7
13
|
*/
|
|
8
14
|
|
|
15
|
+
import type { ConflictReason } from '@cat-factory/contracts'
|
|
9
16
|
import { apiErrorEnvelope } from './api/errors'
|
|
10
17
|
|
|
11
18
|
/** The parsed shape of a backend conflict (`{ error: { code: 'conflict', details } }`). */
|
|
@@ -15,41 +22,55 @@ interface ConflictDetails {
|
|
|
15
22
|
[key: string]: unknown
|
|
16
23
|
}
|
|
17
24
|
|
|
18
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Per-reason toast title KEYS, keyed off the kernel/contracts `ConflictReason`. Being an
|
|
27
|
+
* EXHAUSTIVE `Record` over the union is the real drift guard: a new backend conflict reason
|
|
28
|
+
* fails THIS typecheck until it is mapped here. (The typed-message-keys feature can't see the
|
|
29
|
+
* `t()` lookup because the key is resolved at runtime via this map, not written as a literal —
|
|
30
|
+
* so the exhaustiveness of the map, not `t()`, is what makes a missing reason a build error.)
|
|
31
|
+
* `providers_unconfigured` is excluded: it has bespoke handling + its own `providersUnconfigured.*`
|
|
32
|
+
* key namespace, so it never reaches the generic lookup below.
|
|
33
|
+
*/
|
|
34
|
+
const CONFLICT_TITLE_KEYS: Record<Exclude<ConflictReason, 'providers_unconfigured'>, string> = {
|
|
35
|
+
dependencies_unmet: 'errors.conflict.title.dependencies_unmet',
|
|
36
|
+
task_limit_reached: 'errors.conflict.title.task_limit_reached',
|
|
37
|
+
tester_infra_unsupported: 'errors.conflict.title.tester_infra_unsupported',
|
|
38
|
+
agent_backend_unconfigured: 'errors.conflict.title.agent_backend_unconfigured',
|
|
39
|
+
run_not_retryable: 'errors.conflict.title.run_not_retryable',
|
|
40
|
+
no_pr_to_merge: 'errors.conflict.title.no_pr_to_merge',
|
|
41
|
+
github_not_connected: 'errors.conflict.title.github_not_connected',
|
|
42
|
+
bootstrap_not_retryable: 'errors.conflict.title.bootstrap_not_retryable',
|
|
43
|
+
bootstrap_reference_missing: 'errors.conflict.title.bootstrap_reference_missing',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pull a 409 conflict's `{ reason, message, details }` out of a thrown API error, else null.
|
|
48
|
+
* `message` is the raw backend prose (may be absent); the translated fallback is applied at
|
|
49
|
+
* the call site where the i18n `t` is available.
|
|
50
|
+
*/
|
|
19
51
|
export function parseConflict(
|
|
20
52
|
error: unknown,
|
|
21
|
-
): { reason?: string; message
|
|
53
|
+
): { reason?: string; message?: string; details: ConflictDetails } | null {
|
|
22
54
|
const body = apiErrorEnvelope(error)
|
|
23
55
|
if (body?.code !== 'conflict') return null
|
|
24
56
|
const details = (body.details as ConflictDetails | undefined) ?? {}
|
|
25
57
|
return {
|
|
26
58
|
reason: typeof details.reason === 'string' ? details.reason : undefined,
|
|
27
|
-
message: body.message
|
|
59
|
+
message: typeof body.message === 'string' ? body.message : undefined,
|
|
28
60
|
details,
|
|
29
61
|
}
|
|
30
62
|
}
|
|
31
63
|
|
|
32
|
-
/** Per-reason toast titles for conflicts that don't get bespoke handling below. */
|
|
33
|
-
const CONFLICT_TITLES: Record<string, string> = {
|
|
34
|
-
dependencies_unmet: 'Blocked by dependencies',
|
|
35
|
-
task_limit_reached: 'Concurrency limit reached',
|
|
36
|
-
tester_infra_unsupported: 'Test infrastructure not configured',
|
|
37
|
-
run_not_retryable: 'Run can’t be retried',
|
|
38
|
-
no_pr_to_merge: 'No PR to merge',
|
|
39
|
-
github_not_connected: 'GitHub not connected',
|
|
40
|
-
bootstrap_not_retryable: 'Bootstrap can’t be retried',
|
|
41
|
-
bootstrap_reference_missing: 'Reference architecture is gone',
|
|
42
|
-
}
|
|
43
|
-
|
|
44
64
|
export function usePipelineErrorToast() {
|
|
45
65
|
const toast = useToast()
|
|
46
66
|
const ui = useUiStore()
|
|
67
|
+
const { t, te } = useI18n()
|
|
47
68
|
|
|
48
69
|
/**
|
|
49
|
-
* Present `error` as a toast. `
|
|
50
|
-
* conflict reason without a dedicated title.
|
|
70
|
+
* Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
|
|
71
|
+
* non-conflict failures and any conflict reason without a dedicated title.
|
|
51
72
|
*/
|
|
52
|
-
function present(error: unknown,
|
|
73
|
+
function present(error: unknown, fallbackTitleKey = 'common.actionFailed'): void {
|
|
53
74
|
const conflict = parseConflict(error)
|
|
54
75
|
|
|
55
76
|
// The headline case: a pipeline step's model has no usable provider. Name the
|
|
@@ -59,16 +80,15 @@ export function usePipelineErrorToast() {
|
|
|
59
80
|
const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
|
|
60
81
|
const list = models.join(', ')
|
|
61
82
|
toast.add({
|
|
62
|
-
title: '
|
|
83
|
+
title: t('errors.conflict.providersUnconfigured.title'),
|
|
63
84
|
description: list
|
|
64
|
-
?
|
|
65
|
-
|
|
66
|
-
: conflict.message,
|
|
85
|
+
? t('errors.conflict.providersUnconfigured.body', { models: list })
|
|
86
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
67
87
|
color: 'error',
|
|
68
88
|
icon: 'i-lucide-cpu',
|
|
69
89
|
actions: [
|
|
70
90
|
{
|
|
71
|
-
label: '
|
|
91
|
+
label: t('errors.conflict.providersUnconfigured.action'),
|
|
72
92
|
icon: 'i-lucide-settings',
|
|
73
93
|
onClick: () => ui.openAiProviderSetup(),
|
|
74
94
|
},
|
|
@@ -78,9 +98,14 @@ export function usePipelineErrorToast() {
|
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
if (conflict) {
|
|
101
|
+
// Per-reason title key from the exhaustive map; fall back to the caller's title key when
|
|
102
|
+
// this reason has no mapped/translated copy (`te` = translation-exists, so a key missing
|
|
103
|
+
// in the active locale never leaks as raw text). An unknown reason isn't in the map.
|
|
104
|
+
const reasonKey =
|
|
105
|
+
CONFLICT_TITLE_KEYS[conflict.reason as Exclude<ConflictReason, 'providers_unconfigured'>]
|
|
81
106
|
toast.add({
|
|
82
|
-
title:
|
|
83
|
-
description: conflict.message,
|
|
107
|
+
title: reasonKey && te(reasonKey) ? t(reasonKey) : t(fallbackTitleKey),
|
|
108
|
+
description: conflict.message ?? t('errors.conflict.fallbackMessage'),
|
|
84
109
|
color: 'warning',
|
|
85
110
|
icon: 'i-lucide-triangle-alert',
|
|
86
111
|
})
|
|
@@ -89,7 +114,7 @@ export function usePipelineErrorToast() {
|
|
|
89
114
|
|
|
90
115
|
// Not a conflict (a 4xx/5xx or a network fault) — surface its message plainly.
|
|
91
116
|
toast.add({
|
|
92
|
-
title:
|
|
117
|
+
title: t(fallbackTitleKey),
|
|
93
118
|
description: error instanceof Error ? error.message : String(error),
|
|
94
119
|
color: 'error',
|
|
95
120
|
icon: 'i-lucide-triangle-alert',
|
package/app/stores/agentRuns.ts
CHANGED
package/app/stores/execution.ts
CHANGED
|
@@ -141,7 +141,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
141
141
|
await ws.refresh()
|
|
142
142
|
})
|
|
143
143
|
} catch (e) {
|
|
144
|
-
runErrors.present(e, '
|
|
144
|
+
runErrors.present(e, 'errors.action.startFailed')
|
|
145
145
|
return false
|
|
146
146
|
}
|
|
147
147
|
}
|
|
@@ -240,7 +240,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
240
240
|
await api.mergeBlock(ws.requireId(), blockId)
|
|
241
241
|
await ws.refresh()
|
|
242
242
|
} catch (e) {
|
|
243
|
-
runErrors.present(e, '
|
|
243
|
+
runErrors.present(e, 'errors.action.mergeFailed')
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
@@ -261,7 +261,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
261
261
|
await ws.refresh()
|
|
262
262
|
})
|
|
263
263
|
} catch (e) {
|
|
264
|
-
runErrors.present(e, '
|
|
264
|
+
runErrors.present(e, 'errors.action.restartFailed')
|
|
265
265
|
return false
|
|
266
266
|
}
|
|
267
267
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// vue-i18n options for the @cat-factory/app layer. Referenced from `nuxt.config.ts`
|
|
2
|
+
// as the bare filename `i18n.config.ts` so @nuxtjs/i18n resolves it per-layer (see the
|
|
3
|
+
// `i18n` block there). `defineI18nConfig` is auto-imported by the module.
|
|
4
|
+
//
|
|
5
|
+
// Locale MESSAGES are NOT defined here — they live in `i18n/locales/*.json` so the
|
|
6
|
+
// module can deep-merge them across the `extends` layer chain. This file carries only
|
|
7
|
+
// the runtime vue-i18n behaviour (fallback, number/date formats) shared by every locale.
|
|
8
|
+
export default defineI18nConfig(() => ({
|
|
9
|
+
legacy: false,
|
|
10
|
+
fallbackLocale: 'en',
|
|
11
|
+
|
|
12
|
+
// Locale-aware number/currency formatting. Use `$n(value, 'currency')` etc. at call
|
|
13
|
+
// sites instead of a raw `Intl.NumberFormat`; `$n`/`$d` are thin `Intl` wrappers so
|
|
14
|
+
// `en` behaviour is identical. `currency` style needs a `currency` override per call
|
|
15
|
+
// (`$n(n, 'currency', { currency: s.currency })`) — the backend supplies the code.
|
|
16
|
+
numberFormats: {
|
|
17
|
+
en: {
|
|
18
|
+
decimal: { style: 'decimal' },
|
|
19
|
+
currency: { style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol' },
|
|
20
|
+
percent: { style: 'percent', maximumFractionDigits: 1 },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
datetimeFormats: {
|
|
24
|
+
en: {
|
|
25
|
+
short: { dateStyle: 'medium' },
|
|
26
|
+
long: { dateStyle: 'long', timeStyle: 'short' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"save": "Save",
|
|
4
|
+
"cancel": "Cancel",
|
|
5
|
+
"retry": "Retry",
|
|
6
|
+
"actionFailed": "Action failed"
|
|
7
|
+
},
|
|
8
|
+
"errors": {
|
|
9
|
+
"action": {
|
|
10
|
+
"retryFailed": "Retry failed",
|
|
11
|
+
"startFailed": "Failed to start",
|
|
12
|
+
"mergeFailed": "Failed to merge",
|
|
13
|
+
"restartFailed": "Failed to restart"
|
|
14
|
+
},
|
|
15
|
+
"conflict": {
|
|
16
|
+
"title": {
|
|
17
|
+
"dependencies_unmet": "Blocked by dependencies",
|
|
18
|
+
"task_limit_reached": "Concurrency limit reached",
|
|
19
|
+
"tester_infra_unsupported": "Test infrastructure not configured",
|
|
20
|
+
"agent_backend_unconfigured": "Agent backend not configured",
|
|
21
|
+
"run_not_retryable": "Run can’t be retried",
|
|
22
|
+
"no_pr_to_merge": "No PR to merge",
|
|
23
|
+
"github_not_connected": "GitHub not connected",
|
|
24
|
+
"bootstrap_not_retryable": "Bootstrap can’t be retried",
|
|
25
|
+
"bootstrap_reference_missing": "Reference architecture is gone"
|
|
26
|
+
},
|
|
27
|
+
"fallbackMessage": "This action conflicts with the current state.",
|
|
28
|
+
"providersUnconfigured": {
|
|
29
|
+
"title": "No AI provider for this model",
|
|
30
|
+
"body": "No provider is configured for {models}. Add a provider key, connect a subscription, or enable Cloudflare AI to run it.",
|
|
31
|
+
"action": "Configure AI"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -32,7 +32,29 @@ export default defineNuxtConfig({
|
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
|
|
35
|
-
modules: ['@nuxt/ui', '@pinia/nuxt', 'pinia-plugin-persistedstate/nuxt'],
|
|
35
|
+
modules: ['@nuxt/ui', '@pinia/nuxt', 'pinia-plugin-persistedstate/nuxt', '@nuxtjs/i18n'],
|
|
36
|
+
|
|
37
|
+
// i18n lives in THIS layer's `i18n/` dir (the v9+ `restructureDir` convention).
|
|
38
|
+
// @nuxtjs/i18n is layer-aware: it scans `i18n/locales/` in every layer of the
|
|
39
|
+
// `extends` chain and DEEP-MERGES them (the consumer layer wins on key conflicts),
|
|
40
|
+
// so a downstream deployment can override/add a locale by dropping its own
|
|
41
|
+
// `i18n/locales/*.json` with no change here. Unlike the css block above, the paths
|
|
42
|
+
// here MUST be bare filenames (not `layerDir`-anchored absolutes): the module
|
|
43
|
+
// resolves `vueI18n`/`langDir` per-layer itself, and an absolute path would break
|
|
44
|
+
// that per-layer resolution.
|
|
45
|
+
i18n: {
|
|
46
|
+
// Pure SPA (`ssr: false`): a single in-app locale, no URL-prefix routing.
|
|
47
|
+
strategy: 'no_prefix',
|
|
48
|
+
defaultLocale: 'en',
|
|
49
|
+
locales: [{ code: 'en', language: 'en-US', file: 'en.json', name: 'English' }],
|
|
50
|
+
vueI18n: 'i18n.config.ts',
|
|
51
|
+
experimental: {
|
|
52
|
+
// Generate types from the `en` messages so an unknown `$t`/`t` key is a `nuxt
|
|
53
|
+
// typecheck` failure — the load-bearing maintainability guardrail given the repo
|
|
54
|
+
// lints with oxlint only (no `@intlify/eslint-plugin-vue-i18n` `no-raw-text`).
|
|
55
|
+
typedOptionsAndMessages: 'default',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
36
58
|
|
|
37
59
|
// This is a Nuxt *layer*. @pinia/nuxt's default `storesDirs` is an ABSOLUTE path
|
|
38
60
|
// resolved against the CONSUMER's srcDir, so when this layer is `extends`ed it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"app",
|
|
12
|
+
"i18n",
|
|
12
13
|
"nuxt.config.ts"
|
|
13
14
|
],
|
|
14
15
|
"type": "module",
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@nuxt/ui": "^4.9.0",
|
|
22
|
+
"@nuxtjs/i18n": "^10.4.0",
|
|
21
23
|
"@pinia/nuxt": "^0.11.3",
|
|
22
24
|
"@toad-contracts/core": "0.3.1",
|
|
23
25
|
"@toad-contracts/frontend-http-client": "0.3.1",
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
"pinia-plugin-persistedstate": "^4.7.1",
|
|
33
35
|
"vue": "^3.5.38",
|
|
34
36
|
"wretch": "^3.0.9",
|
|
35
|
-
"@cat-factory/contracts": "0.40.
|
|
37
|
+
"@cat-factory/contracts": "0.40.1"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
40
|
"@toad-contracts/testing": "0.3.1",
|