Ga naar hoofdinhoud

useAppManifest

Composable that loads, resolves, and validates a Conduction app manifest. Backs CnAppRoot and the wider JSON manifest renderer pattern.

Four-phase flow

  1. Synchronous bundled loadbundledManifest is the immediate value of the returned manifest ref. The app shell can render straight away from the bundled copy.
  2. Async backend merge — fetches /index.php/apps/\{appId\}/api/manifest and deep-merges any 200 response over the bundled manifest. 4xx / 5xx / network errors are silently ignored, so apps work without a backend endpoint. The composable uses axios.get from @nextcloud/axios by default (CSRF token attached automatically).
  3. Sentinel resolution@resolve:<key> strings under pages[].config are substituted with IAppConfig values via resolveManifestSentinels. Unresolved keys (unset IAppConfig values) substitute null and surface on the returned unresolvedSentinels ref so consumers can render an admin warning.
  4. Validation — the resolved result is validated by validateManifest. On failure, the bundled manifest is kept and a console.warn is emitted with the error list; validationErrors is set so the consumer can surface the issue. The validator never observes an unresolved sentinel at runtime — the resolved value is what gets validated.

The returned manifest is reactive, so the future "app builder" backend can hot-swap the manifest without a page reload.

Signature

import { useAppManifest } from '@conduction/nextcloud-vue'

const { manifest, isLoading, validationErrors } = useAppManifest(appId, bundledManifest, options)
ArgumentTypeDescription
appIdstringNextcloud app ID. Used to build the default backend endpoint via generateUrl from @nextcloud/router, producing /index.php/apps/\{appId\}/api/manifest.
bundledManifestobjectManifest shipped with the app — the synchronous default value.
options.endpointstringOverride the backend fetch URL. Useful for tests and alternative-host deployments.
options.fetcherFunctionOverride the fetch function. Must return a promise resolving to { status, data }. Defaults to axios.get.
options.getAppConfigValueFunctionOverride the IAppConfig resolver consumed by resolveManifestSentinels. (appId, key) => Promise<value|null>. Defaults to the initial-state-then-fetch chain documented in the resolver.

Return value

KeyTypeDescription
manifestRef<object>The reactive manifest. Starts as bundledManifest; replaced by the resolved + deep-merged result on a successful 200 + valid response.
isLoadingRef<boolean>true while the async fetch is in flight. Pass to CnAppRoot.isLoading to drive the loading phase.
validationErrorsRef<string[] | null>null until the merged manifest fails validation, then the array of validator errors. Stays null on network failures (which fall back silently).
unresolvedSentinelsRef<string[]>List of @resolve:<key> keys whose IAppConfig value resolved to null (unset / empty / fetch failure). Consumers MAY render an admin warning ("3 settings unconfigured") off this list. Empty array [] when every sentinel resolved cleanly.

Usage

Composition API

import { useAppManifest } from '@conduction/nextcloud-vue'
import bundledManifest from './manifest.json'

const { manifest, isLoading } = useAppManifest('decidesk', bundledManifest)

Options API (via setup)

export default {
setup() {
return useAppManifest('decidesk', bundledManifest)
},
}

Tests / alternative endpoints

useAppManifest('decidesk', bundledManifest, {
endpoint: '/custom/manifest/url',
fetcher: (url) => Promise.resolve({ status: 200, data: { /* ... */ } }),
})

@resolve:<key> sentinel

Manifests MAY embed @resolve:<key> strings inside pages[].config.* to defer slug resolution to the consuming app's IAppConfig:

{
"pages": [
{
"id": "voorzieningen-index",
"route": "/voorzieningen",
"type": "index",
"title": "app.voorzieningen",
"config": { "register": "@resolve:voorzieningen_register", "schema": "voorziening" }
}
]
}

When the loader runs, the sentinel is replaced with the result of getAppConfigValue(appId, 'voorzieningen_register'). The validator then sees the resolved string, never the sentinel.

  • Where it works: any string-typed field at any depth under pages[].config. See resolveManifestSentinels for the full sentinel grammar and resolution source chain.
  • Where it doesn't: pages[].id, pages[].route, pages[].component, menu[].route, version, dependencies[], etc. The validator rejects sentinels in those paths because they are router invariants or registry keys.
  • Empty-state behaviour: an unset IAppConfig key resolves to null (NOT empty string) and the key is added to unresolvedSentinels. A console.warn is emitted once per unresolved key.
  • Best practice: consume unresolvedSentinels to render an admin warning when a tenant's manifest has unconfigured slugs. Downstream renderers (e.g. CnIndexPage with config.register === null) should short-circuit to a "not configured" empty state.

Deep-merge semantics

  • Plain objects are merged recursively (own keys from source take precedence).
  • Arrays are replaced, not concatenated — the typical override semantic for manifest fields like menu and pages.
  • Non-object source values short-circuit and replace the target.

Dynamic per-tenant menu entries

The menu[] array is replaced wholesale by any 200 response from the backend /api/manifest endpoint. This is the canonical pattern for per-tenant menu fan-out — apps whose top-level navigation depends on runtime data (catalogues, organisations, registers).

The bundled manifest declares a static placeholder; the backend resolves the per-tenant data and returns a fully-populated menu[]; useAppManifest's deep-merge replaces the bundled list with the resolved one.

Bundled (src/manifest.json)

{
"version": "1.0.0",
"menu": [
{ "id": "catalogs", "label": "menu.catalogs", "route": "catalogs-index" }
],
"pages": [
{ "id": "catalogs-index", "route": "/catalogs", "type": "index", "title": "app.catalogs", "config": { "register": "@resolve:listing_register", "schema": "@resolve:listing_schema" } },
{ "id": "catalog-detail", "route": "/catalogs/:slug", "type": "detail", "title": "app.catalog", "config": { "register": "@resolve:listing_register", "schema": "@resolve:listing_schema" } }
]
}

Backend /index.php/apps/\{appId\}/api/manifest

{
"menu": [
{
"id": "catalogs",
"label": "menu.catalogs",
"route": "catalogs-index",
"children": [
{ "id": "catalog-tax", "label": "menu.catalog.tax", "route": "catalog-detail", "icon": "icon-folder" },
{ "id": "catalog-housing", "label": "menu.catalog.housing", "route": "catalog-detail", "icon": "icon-folder" },
{ "id": "catalog-permits", "label": "menu.catalog.permits", "route": "catalog-detail", "icon": "icon-folder" }
]
}
]
}

After the loader resolves, manifest.menu[0].children carries the three resolved catalogue entries. CnAppNav renders them as nested entries under the parent catalogs group.

Contract

  • Backend-supplied menu[] items MUST validate against the same menuItem / menuItemLeaf $defs the bundled manifest uses. Same fields, same constraints, same one-level nesting limit.
  • label MUST be a translation key resolved by the consuming app's t() function. The backend MUST NOT ship localised free-text labels (per ADR-025 i18n source-of-truth).
  • The backend ships partial manifests — omit pages[] if you only want to override menu[]. The bundled pages[] survives the merge.
  • The bundled manifest SHOULD include placeholder menu entries so the bundled-only path validates and renders something coherent before the backend response arrives.
  • A 404 from the backend is the documented "no override" response — the bundled manifest survives unchanged.

What lives where

ConcernOwner
Decide which menu entries are per-tenant dynamicBackend (per-app concern)
Resolve register / schema slugs into actual cataloguesBackend (via OpenRegister or whatever data source)
Render the merged menuCnAppNav (consumes the resolved manifest.menu)
Validate the merged manifestuseAppManifestvalidateManifest

The lib never directly queries a register or schema. ADR-022 (apps consume OR abstractions) keeps the data layer behind the app's backend; the manifest is the FE expression of the same boundary.

What is NOT supported

A dynamicSource: \{ register, schema, labelField, routeTemplate \} field on menu[] items — the rejected "Option B" design — is not part of the schema. The runtime FE validator is intentionally narrow and does not enforce additionalProperties: false, so a misconfigured backend response carrying such a field passes through silently at runtime. The build-time npm run check:manifest step (using Ajv against the canonical schema) is the canonical gate that rejects unknown fields. See openspec/changes/manifest-dynamic-menu/design.md for the trade-off analysis.

Notes

  • Validation failures do not swap the manifest; the bundled value stays in place and a warning is logged. Apps that want to surface validation errors to users should watch validationErrors.
  • The async merge happens once on call; the composable does not subscribe to a stream. Hot-swap is achieved by mutating manifest.value from elsewhere.
  • CnAppRoot — Primary consumer (drives the loading phase via isLoading).
  • validateManifest — The validator used internally.
  • useAppStatus — Companion composable for dependency detection.