33// Copyright (C) 2026 Conduction B.V.
44//
55// validate-manifest.js — schema-validates src/manifest.json against the
6- // @conduction /nextcloud-vue v1.1.0 app-manifest schema using Ajv.
6+ // @conduction /nextcloud-vue app-manifest schema using Ajv.
77//
88// Usage:
99// node tests/validate-manifest.js
1212// 0 — manifest validates against the schema with zero errors
1313// 1 — manifest fails validation (or schema/manifest cannot be loaded)
1414//
15- // Schema lookup order (first hit wins):
16- // 1. Env var APP_MANIFEST_SCHEMA — explicit absolute path to a schema JSON
17- // 2. node_modules/@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json
18- // 3. ../nextcloud-vue/src/schemas/app-manifest.schema.json (sibling worktree)
19- // 4. /tmp/worktrees/nextcloud-vue-manifest-v1/src/schemas/app-manifest.schema.json (v1.2.0 consolidation worktree)
20- // 5. /tmp/worktrees/nextcloud-vue-page-type-extensions/src/schemas/app-manifest.schema.json (v1.1.0 fallback)
15+ // Schema VARIANT is chosen from the manifest's own `$schema` — a manifest
16+ // pointing at `app-manifest-v2.schema.json` is validated against the v2
17+ // schema, anything else against v1. Hardcoding v1 here (as this script did
18+ // until 2026-08) validated a v2 manifest against the v1 schema, whose
19+ // `$defs.widgetDef` sets `additionalProperties: false` and predates the
20+ // `content` / `icon` / `integrationId` widget keys that CnDetailPage actually
21+ // renders — reporting errors against a manifest that renders correctly and
22+ // passes its own declared schema with zero errors.
2123//
22- // The fourth / fifth options exist because the v1.x schema is not yet
23- // released to npm; the consolidated `manifest-v1` worktree carries the
24- // canonical v1.2.0 source. Once published, options 1 and 2 take over.
24+ // Schema lookup order (first hit wins), for the selected variant:
25+ // 1. Env var APP_MANIFEST_SCHEMA — explicit absolute path to a schema JSON
26+ // 2. node_modules/@conduction/nextcloud-vue/src/schemas/<variant>
27+ // 3. ../nextcloud-vue/src/schemas/<variant> (sibling worktree)
2528
2629'use strict'
2730
@@ -32,16 +35,34 @@ const REPO_ROOT = path.resolve(__dirname, '..')
3235
3336const MANIFEST_PATH = path . join ( REPO_ROOT , 'src' , 'manifest.json' )
3437
35- const SCHEMA_CANDIDATES = [
36- process . env . APP_MANIFEST_SCHEMA ,
37- path . join ( REPO_ROOT , 'node_modules' , '@conduction' , 'nextcloud-vue' , 'src' , 'schemas' , 'app-manifest.schema.json' ) ,
38- path . join ( REPO_ROOT , '..' , 'nextcloud-vue' , 'src' , 'schemas' , 'app-manifest.schema.json' ) ,
39- '/tmp/worktrees/nextcloud-vue-manifest-v1/src/schemas/app-manifest.schema.json' ,
40- '/tmp/worktrees/nextcloud-vue-page-type-extensions/src/schemas/app-manifest.schema.json' ,
41- ] . filter ( Boolean )
38+ /**
39+ * Determine whether the manifest is v2 (points to the v2 $schema URL).
40+ *
41+ * @param {object } manifest Parsed manifest object.
42+ * @return {boolean } True when the manifest targets the v2 schema.
43+ */
44+ function isV2Manifest ( manifest ) {
45+ return typeof manifest . $schema === 'string' && manifest . $schema . includes ( 'app-manifest-v2' )
46+ }
47+
48+ /**
49+ * Build the ordered list of schema file candidates for a given manifest.
50+ * V2 manifests prefer the v2 schema file; v1 manifests prefer the v1 file.
51+ *
52+ * @param {object } manifest Parsed manifest object.
53+ * @return {string[] } Candidate paths (env override first, then node_modules, then sibling worktree).
54+ */
55+ function schemaCandidates ( manifest ) {
56+ const schemaFile = isV2Manifest ( manifest ) ? 'app-manifest-v2.schema.json' : 'app-manifest.schema.json'
57+ return [
58+ process . env . APP_MANIFEST_SCHEMA ,
59+ path . join ( REPO_ROOT , 'node_modules' , '@conduction' , 'nextcloud-vue' , 'src' , 'schemas' , schemaFile ) ,
60+ path . join ( REPO_ROOT , '..' , 'nextcloud-vue' , 'src' , 'schemas' , schemaFile ) ,
61+ ] . filter ( Boolean )
62+ }
4263
43- function findSchemaPath ( ) {
44- for ( const candidate of SCHEMA_CANDIDATES ) {
64+ function findSchemaPath ( manifest ) {
65+ for ( const candidate of schemaCandidates ( manifest ) ) {
4566 try {
4667 if ( fs . existsSync ( candidate ) && fs . statSync ( candidate ) . isFile ( ) ) {
4768 return candidate
@@ -98,7 +119,13 @@ function structuralLint(manifest) {
98119 }
99120 if ( ! Array . isArray ( manifest . menu ) ) errors . push ( 'top-level: menu (array) is required' )
100121 if ( ! Array . isArray ( manifest . pages ) ) errors . push ( 'top-level: pages (array) is required' )
101- const allowedTypes = new Set ( [ 'index' , 'detail' , 'dashboard' , 'logs' , 'settings' , 'chat' , 'files' , 'custom' ] )
122+ // Mirrors $defs/page/properties/type in app-manifest-v2.schema.json — every
123+ // entry is a type CnAppRoot actually renders. Do NOT widen this to make a
124+ // manifest pass; widen it only once the renderer has gained the type.
125+ const allowedTypes = new Set ( [
126+ 'chat' , 'custom' , 'dashboard' , 'detail' , 'files' , 'form' , 'index' ,
127+ 'logs' , 'map' , 'roadmap' , 'search' , 'settings' , 'wiki' ,
128+ ] )
102129 const seenIds = new Set ( )
103130 for ( let i = 0 ; i < ( manifest . pages || [ ] ) . length ; i ++ ) {
104131 const page = manifest . pages [ i ]
@@ -136,7 +163,10 @@ function main() {
136163 console . log ( `[validate-manifest] manifest.version: ${ manifest . version } ` )
137164 console . log ( `[validate-manifest] pages: ${ ( manifest . pages || [ ] ) . length } ` )
138165
139- const schemaPath = findSchemaPath ( )
166+ console . log ( `[validate-manifest] manifest.$schema: ${ manifest . $schema || '(unset)' } ` )
167+ console . log ( `[validate-manifest] schema variant: ${ isV2Manifest ( manifest ) ? 'v2' : 'v1' } ` )
168+
169+ const schemaPath = findSchemaPath ( manifest )
140170 if ( ! schemaPath ) {
141171 console . warn ( '[validate-manifest] no schema candidate resolved; falling back to structural lint.' )
142172 const errors = structuralLint ( manifest )
0 commit comments