@conduction/nextcloud-vue 1.0.0-beta.20 → 1.0.0-beta.22
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/dist/nextcloud-vue.cjs.js +831 -110
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +59 -7
- package/dist/nextcloud-vue.esm.js +830 -111
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/CnSettingsPage/CnSettingsPage.vue +305 -26
- package/src/composables/useAppManifest.js +37 -9
- package/src/index.js +1 -0
- package/src/schemas/app-manifest.schema.json +40 -2
- package/src/utils/resolveManifestSentinels.js +268 -0
- package/src/utils/validateManifest.js +216 -53
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest `@resolve:` sentinel resolver.
|
|
3
|
+
*
|
|
4
|
+
* Implements the `manifest-resolve-sentinel` capability: walks the
|
|
5
|
+
* `pages[].config` subtrees of an assembled manifest and replaces every
|
|
6
|
+
* fully-matched `@resolve:<key>` string with the result of the consuming
|
|
7
|
+
* app's `IAppConfig` lookup for `<key>`. Other manifest paths
|
|
8
|
+
* (`route`, `id`, top-level fields, `menu[]`, `pages[].component` etc.)
|
|
9
|
+
* are intentionally untouched — sentinels there are router / registry
|
|
10
|
+
* invariants and the schema validator rejects them.
|
|
11
|
+
*
|
|
12
|
+
* Resolution source (canonical, per spec):
|
|
13
|
+
*
|
|
14
|
+
* 1. `@nextcloud/initial-state` provisioned key
|
|
15
|
+
* `app-{appId}-{key}` — zero-network, preferred.
|
|
16
|
+
* 2. Runtime `GET /index.php/apps/{appId}/api/configs/{key}` — falls
|
|
17
|
+
* through silently on 4xx / network error.
|
|
18
|
+
* 3. `null` — unresolved.
|
|
19
|
+
*
|
|
20
|
+
* Empty-state semantics: an unset / empty value substitutes `null` (not
|
|
21
|
+
* empty string) and is accumulated into the returned `unresolved` array
|
|
22
|
+
* so consumers can render an admin warning. A `console.warn` is emitted
|
|
23
|
+
* once per unresolved key with the offending sentinel.
|
|
24
|
+
*
|
|
25
|
+
* The resolver is intentionally split into a synchronous walk + an
|
|
26
|
+
* asynchronous batch resolution: every sentinel is collected first, then
|
|
27
|
+
* each unique `(appId, key)` is resolved exactly once (initial-state
|
|
28
|
+
* lookup is synchronous; runtime fetch is per-key cached for the page
|
|
29
|
+
* lifetime), and finally the manifest is rewritten in a second walk
|
|
30
|
+
* with the resolved values. This makes the substitution deterministic
|
|
31
|
+
* — five `@resolve:foo` references in five pages share one fetch.
|
|
32
|
+
*
|
|
33
|
+
* @module utils/resolveManifestSentinels
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const SENTINEL_PATTERN = /^@resolve:([a-z][a-z0-9_-]*)$/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Process-wide cache of resolved IAppConfig values, keyed by
|
|
40
|
+
* `${appId}::${key}`. Cleared via `clearResolveCache()` (test-only).
|
|
41
|
+
*
|
|
42
|
+
* @type {Map<string, Promise<*>>}
|
|
43
|
+
*/
|
|
44
|
+
const resolveCache = new Map()
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Test-only helper to reset the per-page resolve cache between runs.
|
|
48
|
+
* Production callers do not need this — the cache is page-lifetime by
|
|
49
|
+
* design, consistent with the manifest's load-once model.
|
|
50
|
+
*
|
|
51
|
+
* @return {void}
|
|
52
|
+
*/
|
|
53
|
+
export function clearResolveCache() {
|
|
54
|
+
resolveCache.clear()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Walk the manifest's `pages[].config` subtrees and replace every
|
|
59
|
+
* `@resolve:<key>` sentinel with the resolved IAppConfig value.
|
|
60
|
+
*
|
|
61
|
+
* Returns a Promise resolving to `{ manifest, unresolved }`:
|
|
62
|
+
* - `manifest` — a NEW manifest object with sentinels substituted; the
|
|
63
|
+
* input is NOT mutated.
|
|
64
|
+
* - `unresolved` — array of IAppConfig keys whose sentinels resolved
|
|
65
|
+
* to `null` (unset / empty / fetch failure). Useful for surfacing
|
|
66
|
+
* "n settings unconfigured" admin warnings.
|
|
67
|
+
*
|
|
68
|
+
* Sentinels OUTSIDE `pages[].config` are left intact; the schema
|
|
69
|
+
* validator rejects them downstream so consumers see a clear error
|
|
70
|
+
* rather than a silent substitution that breaks routing or registry
|
|
71
|
+
* lookups.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} manifest The merged (bundled + backend) manifest.
|
|
74
|
+
* Walked but not mutated.
|
|
75
|
+
* @param {string} appId Nextcloud app ID. Used to scope the
|
|
76
|
+
* IAppConfig lookup namespace.
|
|
77
|
+
* @param {object} [options] Resolver overrides.
|
|
78
|
+
* @param {Function} [options.getAppConfigValue] Async (appId, key) =>
|
|
79
|
+
* value resolver. Override for tests; defaults to the
|
|
80
|
+
* initial-state-then-fetch chain documented above.
|
|
81
|
+
* @param {Function} [options.warn] Override for `console.warn`. Used in
|
|
82
|
+
* tests to capture warning calls without polluting test output.
|
|
83
|
+
* @return {Promise<{ manifest: object, unresolved: string[] }>}
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveManifestSentinels(manifest, appId, options = {}) {
|
|
86
|
+
if (!isPlainObject(manifest)) {
|
|
87
|
+
return { manifest, unresolved: [] }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const getAppConfigValue = options.getAppConfigValue ?? defaultGetAppConfigValue
|
|
91
|
+
const warn = options.warn ?? ((...args) => {
|
|
92
|
+
// eslint-disable-next-line no-console
|
|
93
|
+
console.warn(...args)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Phase 1: scan for sentinels under pages[].config only. We only
|
|
97
|
+
// need the unique key set — the second walk does the substitution.
|
|
98
|
+
const keys = new Set()
|
|
99
|
+
const pages = Array.isArray(manifest.pages) ? manifest.pages : []
|
|
100
|
+
for (const page of pages) {
|
|
101
|
+
if (!isPlainObject(page) || !isPlainObject(page.config)) continue
|
|
102
|
+
collectSentinelKeys(page.config, keys)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (keys.size === 0) {
|
|
106
|
+
return { manifest, unresolved: [] }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Phase 2: resolve each unique (appId, key) exactly once.
|
|
110
|
+
const resolved = new Map()
|
|
111
|
+
const unresolved = []
|
|
112
|
+
await Promise.all(Array.from(keys).map(async (key) => {
|
|
113
|
+
const value = await getAppConfigValue(appId, key)
|
|
114
|
+
if (value === undefined || value === null || value === '') {
|
|
115
|
+
resolved.set(key, null)
|
|
116
|
+
unresolved.push(key)
|
|
117
|
+
warn(`[resolveManifestSentinels] Manifest sentinel '@resolve:${key}' resolved to null (key unset)`)
|
|
118
|
+
} else {
|
|
119
|
+
resolved.set(key, value)
|
|
120
|
+
}
|
|
121
|
+
}))
|
|
122
|
+
|
|
123
|
+
// Phase 3: rebuild the manifest immutably, substituting sentinels in
|
|
124
|
+
// pages[].config only. Other fields are passed through by reference.
|
|
125
|
+
const out = { ...manifest }
|
|
126
|
+
out.pages = pages.map((page) => {
|
|
127
|
+
if (!isPlainObject(page) || !isPlainObject(page.config)) return page
|
|
128
|
+
return { ...page, config: substituteInTree(page.config, resolved) }
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return { manifest: out, unresolved }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Recursively scan a tree, accumulating every sentinel key into the
|
|
136
|
+
* provided `keys` Set. Plain objects + arrays are descended; primitive
|
|
137
|
+
* leaves are checked against the sentinel pattern.
|
|
138
|
+
*
|
|
139
|
+
* @param {*} node Current tree node (object, array, or primitive).
|
|
140
|
+
* @param {Set<string>} keys Accumulator for unique sentinel keys.
|
|
141
|
+
* @return {void}
|
|
142
|
+
*/
|
|
143
|
+
function collectSentinelKeys(node, keys) {
|
|
144
|
+
if (typeof node === 'string') {
|
|
145
|
+
const match = node.match(SENTINEL_PATTERN)
|
|
146
|
+
if (match) keys.add(match[1])
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(node)) {
|
|
150
|
+
for (const item of node) collectSentinelKeys(item, keys)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (isPlainObject(node)) {
|
|
154
|
+
for (const value of Object.values(node)) collectSentinelKeys(value, keys)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Recursively rebuild a tree, replacing each fully-matched sentinel
|
|
160
|
+
* with its resolved value. Returns a NEW tree; input is unchanged.
|
|
161
|
+
*
|
|
162
|
+
* @param {*} node Current tree node.
|
|
163
|
+
* @param {Map<string,*>} resolved Map of key → resolved value (or null).
|
|
164
|
+
* @return {*} New tree with sentinels substituted.
|
|
165
|
+
*/
|
|
166
|
+
function substituteInTree(node, resolved) {
|
|
167
|
+
if (typeof node === 'string') {
|
|
168
|
+
const match = node.match(SENTINEL_PATTERN)
|
|
169
|
+
if (match && resolved.has(match[1])) {
|
|
170
|
+
return resolved.get(match[1])
|
|
171
|
+
}
|
|
172
|
+
return node
|
|
173
|
+
}
|
|
174
|
+
if (Array.isArray(node)) {
|
|
175
|
+
return node.map((item) => substituteInTree(item, resolved))
|
|
176
|
+
}
|
|
177
|
+
if (isPlainObject(node)) {
|
|
178
|
+
const out = {}
|
|
179
|
+
for (const [key, value] of Object.entries(node)) {
|
|
180
|
+
out[key] = substituteInTree(value, resolved)
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
return node
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Default `getAppConfigValue` implementation: consult
|
|
189
|
+
* `@nextcloud/initial-state` first (zero-network), fall back to a
|
|
190
|
+
* runtime fetch with per-(appId, key) caching for the page lifetime.
|
|
191
|
+
*
|
|
192
|
+
* Returns `null` when neither source resolves a value. Network / 4xx
|
|
193
|
+
* errors are swallowed silently — the resolver downgrades to "key
|
|
194
|
+
* unset" in that case (consistent with the silent-fallback pattern in
|
|
195
|
+
* `useAppManifest`'s backend-merge step).
|
|
196
|
+
*
|
|
197
|
+
* @param {string} appId Nextcloud app ID.
|
|
198
|
+
* @param {string} key IAppConfig key (already validated as
|
|
199
|
+
* lowercase + alphanumeric + `_-` by the sentinel regex).
|
|
200
|
+
* @return {Promise<*>} Resolved value or `null` when unset.
|
|
201
|
+
*/
|
|
202
|
+
async function defaultGetAppConfigValue(appId, key) {
|
|
203
|
+
const cacheKey = `${appId}::${key}`
|
|
204
|
+
if (resolveCache.has(cacheKey)) {
|
|
205
|
+
return resolveCache.get(cacheKey)
|
|
206
|
+
}
|
|
207
|
+
const promise = (async () => {
|
|
208
|
+
// Step 1: initial-state — synchronous, zero-network.
|
|
209
|
+
const initial = readInitialState(appId, key)
|
|
210
|
+
if (initial !== undefined && initial !== null && initial !== '') {
|
|
211
|
+
return initial
|
|
212
|
+
}
|
|
213
|
+
// Step 2: runtime fetch — silent fallback on any error.
|
|
214
|
+
try {
|
|
215
|
+
const { default: axios } = await import('@nextcloud/axios')
|
|
216
|
+
const { generateUrl } = await import('@nextcloud/router')
|
|
217
|
+
const url = generateUrl(`/apps/${appId}/api/configs/${key}`)
|
|
218
|
+
const response = await axios.get(url)
|
|
219
|
+
if (response && response.status === 200 && response.data !== undefined) {
|
|
220
|
+
const data = response.data
|
|
221
|
+
// API may return either a raw scalar or `{ value: ... }`.
|
|
222
|
+
if (isPlainObject(data) && 'value' in data) return data.value
|
|
223
|
+
return data
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// Silent — caller treats as "unset".
|
|
227
|
+
}
|
|
228
|
+
return null
|
|
229
|
+
})()
|
|
230
|
+
resolveCache.set(cacheKey, promise)
|
|
231
|
+
return promise
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Read a key from `@nextcloud/initial-state`. Looks up the conventional
|
|
236
|
+
* `app-{appId}-{key}` slot. Returns `undefined` when the package is not
|
|
237
|
+
* installed (e.g. older host) or the key is not provisioned.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} appId Nextcloud app ID.
|
|
240
|
+
* @param {string} key IAppConfig key.
|
|
241
|
+
* @return {*} Provisioned value or `undefined`.
|
|
242
|
+
*/
|
|
243
|
+
function readInitialState(appId, key) {
|
|
244
|
+
try {
|
|
245
|
+
// `@nextcloud/initial-state` is an optional peer; the host page
|
|
246
|
+
// may not provision the slot at all. We resolve via require so
|
|
247
|
+
// jest mocks the import; bundle-side, the package is treeshaken
|
|
248
|
+
// when no caller pulls it in.
|
|
249
|
+
// eslint-disable-next-line global-require, import/no-unresolved, n/no-extraneous-require
|
|
250
|
+
const mod = require('@nextcloud/initial-state')
|
|
251
|
+
if (typeof mod.loadState === 'function') {
|
|
252
|
+
return mod.loadState(appId, key, undefined)
|
|
253
|
+
}
|
|
254
|
+
} catch (e) {
|
|
255
|
+
// Package not installed or no slot provisioned — fall through.
|
|
256
|
+
}
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Type guard — true when value is a plain (non-array, non-null) object.
|
|
262
|
+
*
|
|
263
|
+
* @param {*} value Candidate.
|
|
264
|
+
* @return {boolean} True when value is a plain object.
|
|
265
|
+
*/
|
|
266
|
+
function isPlainObject(value) {
|
|
267
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
268
|
+
}
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern matching the `manifest-resolve-sentinel` capability's
|
|
3
|
+
* sentinel — `@resolve:<key>` where `<key>` is lowercase alphanumeric
|
|
4
|
+
* with `_` / `-` separators. The full string IS the sentinel; partial
|
|
5
|
+
* substitution like `prefix-@resolve:foo` is NOT supported and is left
|
|
6
|
+
* as a plain string for downstream renderers.
|
|
7
|
+
*
|
|
8
|
+
* Build-time validation accepts this pattern as a valid `string` for
|
|
9
|
+
* any `string`-typed field UNDER `pages[].config`, regardless of any
|
|
10
|
+
* narrower per-field constraint. Other paths reject it explicitly.
|
|
11
|
+
*/
|
|
12
|
+
const SENTINEL_PATTERN = /^@resolve:[a-z][a-z0-9_-]*$/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Test whether a string is a manifest `@resolve:` sentinel.
|
|
16
|
+
*
|
|
17
|
+
* @param {*} value Candidate value.
|
|
18
|
+
* @return {boolean} True when the value is a fully-matched sentinel.
|
|
19
|
+
*/
|
|
20
|
+
function isSentinel(value) {
|
|
21
|
+
return typeof value === 'string' && SENTINEL_PATTERN.test(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
1
24
|
/**
|
|
2
25
|
* Validate a manifest object against the manifest JSON Schema.
|
|
3
26
|
*
|
|
@@ -15,6 +38,13 @@
|
|
|
15
38
|
* - `pages[].component` is required when `type` is "custom".
|
|
16
39
|
* - Per-type `config` shape rules for the built-in types `logs`,
|
|
17
40
|
* `settings`, `chat`, `files` (REQ from manifest-page-type-extensions).
|
|
41
|
+
* - The `manifest-resolve-sentinel` sentinel `@resolve:<key>` is
|
|
42
|
+
* permissively accepted under `pages[].config.*` and explicitly
|
|
43
|
+
* REJECTED in `version`, `dependencies[]`, `menu[].route`,
|
|
44
|
+
* `menu[].id`, `pages[].id`, `pages[].route`, `pages[].component`,
|
|
45
|
+
* `pages[].headerComponent`, `pages[].actionsComponent`,
|
|
46
|
+
* `pages[].slots.*` — those are router invariants or registry
|
|
47
|
+
* keys.
|
|
18
48
|
*
|
|
19
49
|
* The richer schema constraints (`additionalProperties: false`, `format`
|
|
20
50
|
* URI, etc.) are enforced by the BE / hydra CI validators that consume
|
|
@@ -42,6 +72,10 @@ export function validateManifest(manifest, options = {}) {
|
|
|
42
72
|
|
|
43
73
|
if (typeof manifest.version !== 'string') {
|
|
44
74
|
errors.push('/version must be a string')
|
|
75
|
+
} else if (isSentinel(manifest.version)) {
|
|
76
|
+
// `manifest-resolve-sentinel` REQ-MRS-004: sentinel is a router /
|
|
77
|
+
// registry invariant violation when used here.
|
|
78
|
+
errors.push(`/version "${manifest.version}" must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
45
79
|
} else if (!versionPattern.test(manifest.version)) {
|
|
46
80
|
errors.push(`/version "${manifest.version}" must match semver pattern`)
|
|
47
81
|
}
|
|
@@ -54,8 +88,15 @@ export function validateManifest(manifest, options = {}) {
|
|
|
54
88
|
errors.push(`/menu/${index} must be an object`)
|
|
55
89
|
return
|
|
56
90
|
}
|
|
57
|
-
if (typeof item.id !== 'string')
|
|
91
|
+
if (typeof item.id !== 'string') {
|
|
92
|
+
errors.push(`/menu/${index}/id must be a string`)
|
|
93
|
+
} else if (isSentinel(item.id)) {
|
|
94
|
+
errors.push(`/menu/${index}/id must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
95
|
+
}
|
|
58
96
|
if (typeof item.label !== 'string') errors.push(`/menu/${index}/label must be a string`)
|
|
97
|
+
if (item.route !== undefined && isSentinel(item.route)) {
|
|
98
|
+
errors.push(`/menu/${index}/route must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
99
|
+
}
|
|
59
100
|
if (item.children !== undefined && !Array.isArray(item.children)) {
|
|
60
101
|
errors.push(`/menu/${index}/children must be an array`)
|
|
61
102
|
}
|
|
@@ -77,12 +118,18 @@ export function validateManifest(manifest, options = {}) {
|
|
|
77
118
|
}
|
|
78
119
|
if (typeof page.id !== 'string') {
|
|
79
120
|
errors.push(`/pages/${index}/id must be a string`)
|
|
121
|
+
} else if (isSentinel(page.id)) {
|
|
122
|
+
errors.push(`/pages/${index}/id must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
80
123
|
} else if (seenIds.has(page.id)) {
|
|
81
124
|
errors.push(`/pages/${index}/id "${page.id}" must be unique within pages[]`)
|
|
82
125
|
} else {
|
|
83
126
|
seenIds.add(page.id)
|
|
84
127
|
}
|
|
85
|
-
if (typeof page.route !== 'string')
|
|
128
|
+
if (typeof page.route !== 'string') {
|
|
129
|
+
errors.push(`/pages/${index}/route must be a string`)
|
|
130
|
+
} else if (isSentinel(page.route)) {
|
|
131
|
+
errors.push(`/pages/${index}/route must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
132
|
+
}
|
|
86
133
|
if (typeof page.title !== 'string') errors.push(`/pages/${index}/title must be a string`)
|
|
87
134
|
if (typeof page.type !== 'string' || page.type.length === 0) {
|
|
88
135
|
errors.push(`/pages/${index}/type must be a non-empty string`)
|
|
@@ -92,6 +139,25 @@ export function validateManifest(manifest, options = {}) {
|
|
|
92
139
|
if (page.type === 'custom' && typeof page.component !== 'string') {
|
|
93
140
|
errors.push(`/pages/${index}/component is required when type is "custom"`)
|
|
94
141
|
}
|
|
142
|
+
// `manifest-resolve-sentinel` REQ-MRS-004: registry-key
|
|
143
|
+
// fields cannot be dynamic — they resolve at module-load
|
|
144
|
+
// time against `customComponents`, before the loader runs.
|
|
145
|
+
if (page.component !== undefined && isSentinel(page.component)) {
|
|
146
|
+
errors.push(`/pages/${index}/component must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
147
|
+
}
|
|
148
|
+
if (page.headerComponent !== undefined && isSentinel(page.headerComponent)) {
|
|
149
|
+
errors.push(`/pages/${index}/headerComponent must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
150
|
+
}
|
|
151
|
+
if (page.actionsComponent !== undefined && isSentinel(page.actionsComponent)) {
|
|
152
|
+
errors.push(`/pages/${index}/actionsComponent must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
153
|
+
}
|
|
154
|
+
if (isPlainObject(page.slots)) {
|
|
155
|
+
for (const [slotName, slotValue] of Object.entries(page.slots)) {
|
|
156
|
+
if (isSentinel(slotValue)) {
|
|
157
|
+
errors.push(`/pages/${index}/slots/${slotName} must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
95
161
|
|
|
96
162
|
// Per-type config-shape validation for built-in extended types.
|
|
97
163
|
// (`manifest-page-type-extensions` spec — covers logs/settings/chat/files.)
|
|
@@ -115,6 +181,8 @@ export function validateManifest(manifest, options = {}) {
|
|
|
115
181
|
manifest.dependencies.forEach((dep, index) => {
|
|
116
182
|
if (typeof dep !== 'string') {
|
|
117
183
|
errors.push(`/dependencies/${index} must be a string`)
|
|
184
|
+
} else if (isSentinel(dep)) {
|
|
185
|
+
errors.push(`/dependencies/${index} must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
|
|
118
186
|
}
|
|
119
187
|
})
|
|
120
188
|
}
|
|
@@ -178,7 +246,70 @@ function validateTypeConfig(page, index, errors) {
|
|
|
178
246
|
break
|
|
179
247
|
}
|
|
180
248
|
case 'settings': {
|
|
181
|
-
|
|
249
|
+
// `manifest-settings-orchestration` REQ-MSO-1: a settings page
|
|
250
|
+
// MUST declare EXACTLY ONE of `sections` | `tabs`. When both
|
|
251
|
+
// are set, emit the orchestration mutex error. When neither is
|
|
252
|
+
// set, fall through to the legacy `sections required` error
|
|
253
|
+
// (back-compat — REQ-MSO-7 / REQ-MSO-1 last scenario).
|
|
254
|
+
const hasSections = cfg && Array.isArray(cfg.sections)
|
|
255
|
+
const hasTabs = cfg && Array.isArray(cfg.tabs)
|
|
256
|
+
|
|
257
|
+
if (hasSections && hasTabs) {
|
|
258
|
+
errors.push(`${pathSlash}: ${pathBracket}: must declare exactly one of sections | tabs`)
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (hasTabs) {
|
|
263
|
+
// `manifest-settings-orchestration` REQ-MSO-2..4: validate
|
|
264
|
+
// the `tabs[]` orchestration shape.
|
|
265
|
+
if (cfg.tabs.length === 0) {
|
|
266
|
+
errors.push(`${pathSlash}/tabs: ${pathBracket}.tabs: must contain at least 1 tab`)
|
|
267
|
+
break
|
|
268
|
+
}
|
|
269
|
+
const seenTabIds = Object.create(null)
|
|
270
|
+
cfg.tabs.forEach((tab, tIndex) => {
|
|
271
|
+
if (!isPlainObject(tab)) {
|
|
272
|
+
errors.push(`${pathSlash}/tabs/${tIndex}: must be an object`)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (typeof tab.id !== 'string' || tab.id.length === 0) {
|
|
276
|
+
errors.push(`${pathSlash}/tabs/${tIndex}/id: required, must be a non-empty string`)
|
|
277
|
+
}
|
|
278
|
+
if (typeof tab.label !== 'string' || tab.label.length === 0) {
|
|
279
|
+
errors.push(`${pathSlash}/tabs/${tIndex}/label: required, must be a non-empty string`)
|
|
280
|
+
}
|
|
281
|
+
// REQ-MSO-3: tab IDs must be unique within a page.
|
|
282
|
+
if (typeof tab.id === 'string' && tab.id.length > 0) {
|
|
283
|
+
if (seenTabIds[tab.id]) {
|
|
284
|
+
errors.push(`${pathSlash}/tabs/${tIndex}/id: ${pathBracket}.tabs[${tIndex}].id: duplicate id "${tab.id}" — tab IDs must be unique within a page`)
|
|
285
|
+
}
|
|
286
|
+
seenTabIds[tab.id] = true
|
|
287
|
+
}
|
|
288
|
+
// `tab.sections` MUST be a non-empty array.
|
|
289
|
+
if (!Array.isArray(tab.sections)) {
|
|
290
|
+
errors.push(`${pathSlash}/tabs/${tIndex}/sections: ${pathBracket}.tabs[${tIndex}].sections: required, must be an array`)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (tab.sections.length === 0) {
|
|
294
|
+
errors.push(`${pathSlash}/tabs/${tIndex}/sections: ${pathBracket}.tabs[${tIndex}].sections: must contain at least 1 section`)
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
// REQ-MSO-4: each tab's sections follow the same rules
|
|
298
|
+
// as the flat case — share the per-section validator.
|
|
299
|
+
tab.sections.forEach((section, sIndex) => {
|
|
300
|
+
validateSettingsSection(
|
|
301
|
+
section,
|
|
302
|
+
`${pathSlash}/tabs/${tIndex}/sections/${sIndex}`,
|
|
303
|
+
`${pathBracket}.tabs[${tIndex}].sections[${sIndex}]`,
|
|
304
|
+
errors,
|
|
305
|
+
)
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Flat `sections[]` (existing path — REQ-MSRS-* + back-compat).
|
|
312
|
+
if (!hasSections) {
|
|
182
313
|
errors.push(`${pathSlash}/sections: ${pathBracket}.sections: required, must be an array`)
|
|
183
314
|
break
|
|
184
315
|
}
|
|
@@ -187,56 +318,12 @@ function validateTypeConfig(page, index, errors) {
|
|
|
187
318
|
break
|
|
188
319
|
}
|
|
189
320
|
cfg.sections.forEach((section, sIndex) => {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// `manifest-settings-rich-sections` REQ-MSRS-1: each
|
|
199
|
-
// section MUST declare exactly one of fields | component
|
|
200
|
-
// | widgets. Mixed bodies confuse the renderer + duplicate
|
|
201
|
-
// the section chrome; empty bodies render nothing so they
|
|
202
|
-
// are a manifest-author bug.
|
|
203
|
-
const hasFields = Array.isArray(section.fields)
|
|
204
|
-
const hasComponent = typeof section.component === 'string' && section.component.length > 0
|
|
205
|
-
const hasWidgets = Array.isArray(section.widgets) && section.widgets.length > 0
|
|
206
|
-
const bodyCount = (hasFields ? 1 : 0) + (hasComponent ? 1 : 0) + (hasWidgets ? 1 : 0)
|
|
207
|
-
|
|
208
|
-
if (bodyCount !== 1) {
|
|
209
|
-
errors.push(`${pathSlash}/sections/${sIndex}: ${pathBracket}.sections[${sIndex}]: must declare exactly one of fields | component | widgets`)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// `widgets` set but not an array (string / object / etc.)
|
|
213
|
-
if (section.widgets !== undefined && !Array.isArray(section.widgets)) {
|
|
214
|
-
errors.push(`${pathSlash}/sections/${sIndex}/widgets: must be an array when set`)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// `component` set but not a string.
|
|
218
|
-
if (section.component !== undefined && typeof section.component !== 'string') {
|
|
219
|
-
errors.push(`${pathSlash}/sections/${sIndex}/component: must be a string when set`)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Per-widget shape rules.
|
|
223
|
-
if (hasWidgets) {
|
|
224
|
-
section.widgets.forEach((widget, wIndex) => {
|
|
225
|
-
if (!isPlainObject(widget)) {
|
|
226
|
-
errors.push(`${pathSlash}/sections/${sIndex}/widgets/${wIndex}: must be an object`)
|
|
227
|
-
return
|
|
228
|
-
}
|
|
229
|
-
if (typeof widget.type !== 'string' || widget.type.length === 0) {
|
|
230
|
-
errors.push(`${pathSlash}/sections/${sIndex}/widgets/${wIndex}/type: must be a non-empty string`)
|
|
231
|
-
}
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// `manifest-config-refs` REQ-MCR — when fields[] body is
|
|
236
|
-
// used, each entry must match the formField $def shape.
|
|
237
|
-
if (hasFields) {
|
|
238
|
-
validateFieldsArray(section.fields, `${pathSlash}/sections/${sIndex}/fields`, errors)
|
|
239
|
-
}
|
|
321
|
+
validateSettingsSection(
|
|
322
|
+
section,
|
|
323
|
+
`${pathSlash}/sections/${sIndex}`,
|
|
324
|
+
`${pathBracket}.sections[${sIndex}]`,
|
|
325
|
+
errors,
|
|
326
|
+
)
|
|
240
327
|
})
|
|
241
328
|
break
|
|
242
329
|
}
|
|
@@ -669,6 +756,82 @@ function validateLayoutArray(cfg, pathSlash, pathBracket, errors) {
|
|
|
669
756
|
})
|
|
670
757
|
}
|
|
671
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Validate a single `sections[]` entry for `type:"settings"` pages.
|
|
761
|
+
* Shared between the flat `pages[].config.sections[]` path AND the
|
|
762
|
+
* tab-nested `pages[].config.tabs[].sections[]` path
|
|
763
|
+
* (`manifest-settings-orchestration` REQ-MSO-4).
|
|
764
|
+
*
|
|
765
|
+
* Enforces the rich-sections REQ-MSRS-1 mutex (`fields | component |
|
|
766
|
+
* widgets` exactly-one-of) plus per-widget shape rules. The new
|
|
767
|
+
* `widget.type === "component"` discriminator (REQ-MSO-6) requires
|
|
768
|
+
* `componentName: <non-empty string>`.
|
|
769
|
+
*
|
|
770
|
+
* @param {*} section The section under validation
|
|
771
|
+
* @param {string} pathSlash JSON-pointer-style path prefix for errors
|
|
772
|
+
* @param {string} pathBracket Human-readable bracket-path for errors
|
|
773
|
+
* @param {string[]} errors Accumulator
|
|
774
|
+
*/
|
|
775
|
+
function validateSettingsSection(section, pathSlash, pathBracket, errors) {
|
|
776
|
+
if (!isPlainObject(section)) {
|
|
777
|
+
errors.push(`${pathSlash}: must be an object`)
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
if (typeof section.title !== 'string') {
|
|
781
|
+
errors.push(`${pathSlash}/title: required, must be a string`)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// `manifest-settings-rich-sections` REQ-MSRS-1: exactly one of
|
|
785
|
+
// fields | component | widgets.
|
|
786
|
+
const hasFields = Array.isArray(section.fields)
|
|
787
|
+
const hasComponent = typeof section.component === 'string' && section.component.length > 0
|
|
788
|
+
const hasWidgets = Array.isArray(section.widgets) && section.widgets.length > 0
|
|
789
|
+
const bodyCount = (hasFields ? 1 : 0) + (hasComponent ? 1 : 0) + (hasWidgets ? 1 : 0)
|
|
790
|
+
|
|
791
|
+
if (bodyCount !== 1) {
|
|
792
|
+
errors.push(`${pathSlash}: ${pathBracket}: must declare exactly one of fields | component | widgets`)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// `widgets` set but not an array (string / object / etc.)
|
|
796
|
+
if (section.widgets !== undefined && !Array.isArray(section.widgets)) {
|
|
797
|
+
errors.push(`${pathSlash}/widgets: must be an array when set`)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// `component` set but not a string.
|
|
801
|
+
if (section.component !== undefined && typeof section.component !== 'string') {
|
|
802
|
+
errors.push(`${pathSlash}/component: must be a string when set`)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Per-widget shape rules.
|
|
806
|
+
if (hasWidgets) {
|
|
807
|
+
section.widgets.forEach((widget, wIndex) => {
|
|
808
|
+
if (!isPlainObject(widget)) {
|
|
809
|
+
errors.push(`${pathSlash}/widgets/${wIndex}: must be an object`)
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
if (typeof widget.type !== 'string' || widget.type.length === 0) {
|
|
813
|
+
errors.push(`${pathSlash}/widgets/${wIndex}/type: must be a non-empty string`)
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
// `manifest-settings-orchestration` REQ-MSO-6: when the
|
|
817
|
+
// discriminator is "component", `componentName` MUST be a
|
|
818
|
+
// non-empty string. Other widget types ignore
|
|
819
|
+
// `componentName`.
|
|
820
|
+
if (widget.type === 'component') {
|
|
821
|
+
if (typeof widget.componentName !== 'string' || widget.componentName.length === 0) {
|
|
822
|
+
errors.push(`${pathSlash}/widgets/${wIndex}/componentName: required when type is "component", must be a non-empty string`)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
})
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// `manifest-config-refs` REQ-MCR — when fields[] body is used,
|
|
829
|
+
// each entry must match the formField $def shape.
|
|
830
|
+
if (hasFields) {
|
|
831
|
+
validateFieldsArray(section.fields, `${pathSlash}/fields`, errors)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
672
835
|
/**
|
|
673
836
|
* Validate `config.sections[].fields[]` for settings page type
|
|
674
837
|
* (`manifest-config-refs` REQ-MCR). Each field MUST be an object with
|