@ossy/app 1.40.2 → 1.40.3

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.
@@ -4,6 +4,16 @@ import { pathToFileURL } from 'node:url'
4
4
 
5
5
  import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.js'
6
6
  import { discoverPackageDefinitions } from '../src/manifest/discover-package-definitions.js'
7
+ import { buildActionsSchema } from '../src/manifest/build-actions-schema.js'
8
+ import { buildCapabilities } from '../src/manifest/build-capabilities.js'
9
+ import { extractTaskCatalogEntry, buildTaskCatalogEdges } from '../src/manifest/extract-task-catalog.js'
10
+ import {
11
+ Schema,
12
+ isActionId,
13
+ taskIdFromActionId,
14
+ CAPABILITY_SCHEMA_ID,
15
+ } from '@ossy/schema'
16
+ import { isCanonicalSchemaId } from '@ossy/schema'
7
17
 
8
18
  /**
9
19
  * @typedef {import('./get-platform-files.task.js').PlatformEntry} PlatformEntry
@@ -62,14 +72,19 @@ import { discoverPackageDefinitions } from '../src/manifest/discover-package-def
62
72
  * @property {string} entry URL the platform serves the startup bundle from.
63
73
  *
64
74
  * @typedef {object} EmailManifestEntry
65
- * @property {string} id Unique email id (e.g. `'authentication/verify-sign-in'`).
75
+ * @property {string} id Unique email id (e.g. `'@ossy/authentication/emails/verify-sign-in'`).
66
76
  * @property {string} entry URL the platform serves the email bundle from.
67
77
  *
68
78
  * @typedef {object} ActionManifestEntry
69
- * @property {string} id Unique action slug (e.g. `'authentication/request-sign-in'`).
79
+ * @property {string} id Unique action slug (e.g. `'@ossy/authentication/actions/request-sign-in'`).
70
80
  * @property {string} entry URL the platform serves the action bundle from.
71
81
  * @property {string} access Access level: `'public'` | `'authenticated'` | `'workspace'`.
72
82
  *
83
+ * @typedef {object} FormManifestEntry
84
+ * @property {string} id Unique form slug (e.g. `'@ossy/authentication/form/sign-up'`).
85
+ * @property {string} schemaId Resource schema id (e.g. `'@ossy/authentication/sign-up'`).
86
+ * @property {string} entry URL the platform serves the form bundle from.
87
+ *
73
88
  * @typedef {object} LayoutManifestEntry
74
89
  * @property {string} id Unique layout id (e.g. `'app-shell'`).
75
90
  * @property {string} entry URL the platform serves the layout bundle from.
@@ -82,16 +97,24 @@ import { discoverPackageDefinitions } from '../src/manifest/discover-package-def
82
97
  * @property {string[]} requires Runtime dependencies needed (e.g. `['server', 'database']`).
83
98
  * @property {string} entry URL the platform serves the e2e bundle from.
84
99
  *
100
+ * @typedef {object} FlowManifestEntry
101
+ * @property {string} id Unique flow id (e.g. `'@ossy/authentication/actions/sign-up'`).
102
+ * @property {string} feature Grouping label (e.g. `'authentication'`).
103
+ * @property {string[]} requires Runtime dependencies needed (e.g. `['server', 'database']`).
104
+ * @property {string} entry URL the platform serves the flow bundle from.
105
+ *
85
106
  * @typedef {object} Manifest
86
107
  * @property {ManifestEntry[]} entries Flat, ordered list of every routable thing.
87
108
  * @property {ComponentManifestEntry[]} components Discovered `*.component.*` entries, keyed by id.
88
- * @property {object[]} resourceTemplates Each `*.resource.js` file's default-exported template object.
109
+ * @property {object[]} schemas Each `*.schema.js` file's default-exported template object.
89
110
  * @property {AggregateManifestEntry[]} aggregates Discovered `*.aggregate.js` entries from installed packages.
90
111
  * @property {IntegrationManifestEntry[]} integrations Discovered `*.integration.js` entries.
91
112
  * @property {StartupManifestEntry[]} startups Discovered `*.startup.js` entries.
92
113
  * @property {EmailManifestEntry[]} emails Discovered `*.email.{jsx,tsx}` entries.
93
114
  * @property {ActionManifestEntry[]} actions Discovered `*.action.js` entries.
115
+ * @property {FormManifestEntry[]} forms Discovered `*.form.js` entries.
94
116
  * @property {E2eManifestEntry[]} e2es Discovered `*.e2e.js` entries from installed packages.
117
+ * @property {FlowManifestEntry[]} flows Discovered `*.flow.js` entries from installed packages.
95
118
  * @property {LayoutManifestEntry[]} layouts Discovered `*.layout.{jsx,tsx}` entries (includes app `slots` map).
96
119
  * @property {object} config Inlined `src/config.js` default export.
97
120
  * @property {Record<string, object>} [definitions] Package Definition metadata keyed by slug.
@@ -131,6 +154,12 @@ import { discoverPackageDefinitions } from '../src/manifest/discover-package-def
131
154
  * @param {ManifestPluginOptions} options
132
155
  * @returns {import('rollup').Plugin}
133
156
  */
157
+ function reportValidationError (plugin, result, sourcePath) {
158
+ if (result.ok) return
159
+ const message = result.errors?.[0]?.message ?? result.message ?? 'Validation failed'
160
+ plugin.error(`[@ossy/app][build] ${sourcePath}: ${message}`)
161
+ }
162
+
134
163
  function entryPackage (entryInfo, appPackageName) {
135
164
  return entryInfo.packageName || appPackageName
136
165
  }
@@ -141,8 +170,10 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
141
170
  async writeBundle (_, bundle) {
142
171
  /** @type {ManifestEntry[]} */
143
172
  const entries = []
173
+ /** @type {Record<string, { path: string, method?: string, query?: string[] }>} */
174
+ const actionRoutes = {}
144
175
  /** @type {object[]} */
145
- const resourceTemplates = []
176
+ const schemas = []
146
177
  /** @type {import('./get-platform-files.task.js').ComponentManifestEntry[]} */
147
178
  const components = []
148
179
  /** @type {AggregateManifestEntry[]} */
@@ -155,6 +186,8 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
155
186
  const emails = []
156
187
  /** @type {ActionManifestEntry[]} */
157
188
  const actions = []
189
+ /** @type {FormManifestEntry[]} */
190
+ const forms = []
158
191
  const seenIds = { page: new Set(), api: new Set(), task: new Set(), component: new Set() }
159
192
  const seenResourceIds = new Set()
160
193
  const seenAggregateIds = new Set()
@@ -162,12 +195,18 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
162
195
  const seenStartupIds = new Set()
163
196
  const seenEmailIds = new Set()
164
197
  const seenActionIds = new Set()
198
+ const seenFormIds = new Set()
165
199
  /** @type {E2eManifestEntry[]} */
166
200
  const e2es = []
167
201
  const seenE2eIds = new Set()
202
+ /** @type {FlowManifestEntry[]} */
203
+ const flows = []
204
+ const seenFlowIds = new Set()
168
205
  /** @type {LayoutManifestEntry[]} */
169
206
  const layouts = []
170
207
  const seenLayoutIds = new Set()
208
+ /** @type {Array<{ mod: object, entryInfo: import('./get-platform-files.task.js').PlatformEntry, url: string, pkg: string }>} */
209
+ const taskPending = []
171
210
  for (const fileName of Object.keys(bundle)) {
172
211
  const chunk = bundle[fileName]
173
212
  if (chunk.type !== 'chunk' || !chunk.isEntry) continue
@@ -268,8 +307,8 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
268
307
  continue
269
308
  }
270
309
 
271
- // Actions are intent: `metadata` only (`{ id, access }`). Matching task
272
- // with the same id holds `run` (see ADR 0003).
310
+ // Actions are intent: `metadata` only (`{ id, access }`). Primary implementation
311
+ // lives in `{feature}/tasks/{intent}` derived from the action id (ADR 0003).
273
312
  if (entryInfo.kind === 'action') {
274
313
  const actionId = mod?.metadata?.id
275
314
  if (typeof actionId !== 'string' || actionId.trim() === '') {
@@ -281,11 +320,47 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
281
320
  this.error(`[@ossy/app][build] Duplicate action id "${actionId}"`)
282
321
  }
283
322
  const access = mod.metadata?.access ?? 'authenticated'
323
+ reportValidationError(
324
+ this,
325
+ Schema.of({ schemas: [] }).validate(
326
+ CAPABILITY_SCHEMA_ID.ACTION_META,
327
+ { id: actionId, ...(mod.metadata?.access != null ? { access: mod.metadata.access } : {}) },
328
+ { sourcePath: entryInfo.sourcePath },
329
+ ),
330
+ entryInfo.sourcePath,
331
+ )
284
332
  seenActionIds.add(actionId)
285
333
  actions.push({ id: actionId, entry: url, access, package: entryPackage(entryInfo, appPackageName) })
286
334
  continue
287
335
  }
288
336
 
337
+ // Forms are intent metadata (`metadata` only) referencing a resource template.
338
+ if (entryInfo.kind === 'form') {
339
+ const formId = mod?.metadata?.id
340
+ const schemaId = mod?.metadata?.schemaId
341
+ if (typeof formId !== 'string' || formId.trim() === '') {
342
+ this.error(
343
+ `[@ossy/app][build] form entry ${entryInfo.sourcePath} must export a non-empty string "metadata.id"`,
344
+ )
345
+ }
346
+ if (typeof schemaId !== 'string' || schemaId.trim() === '') {
347
+ this.error(
348
+ `[@ossy/app][build] form entry ${entryInfo.sourcePath} must export a non-empty string "metadata.schemaId"`,
349
+ )
350
+ }
351
+ if (seenFormIds.has(formId)) {
352
+ this.error(`[@ossy/app][build] Duplicate form id "${formId}"`)
353
+ }
354
+ seenFormIds.add(formId)
355
+ forms.push({
356
+ id: formId,
357
+ schemaId,
358
+ entry: url,
359
+ package: entryPackage(entryInfo, appPackageName),
360
+ })
361
+ continue
362
+ }
363
+
289
364
  // Aggregates are server-side classes — the bundle exports `Aggregate`
290
365
  // (the class) and `id` (the AggregateType string). They don't produce
291
366
  // routable entries; they accumulate into a separate `aggregates` array.
@@ -344,6 +419,36 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
344
419
  continue
345
420
  }
346
421
 
422
+ if (entryInfo.kind === 'flow') {
423
+ const flowMeta = mod?.metadata ?? mod?.default?.metadata ?? {}
424
+ const flowId = flowMeta.id ?? mod?.default?.id
425
+ const feature = flowMeta.feature
426
+ const requires = flowMeta.requires
427
+ const flowBody = mod?.default
428
+ if (typeof flowId !== 'string' || flowId.trim() === '') {
429
+ this.error(
430
+ `[@ossy/app][build] flow entry ${entryInfo.sourcePath} must export metadata.id`,
431
+ )
432
+ }
433
+ if (!flowBody || typeof flowBody !== 'object' || !Array.isArray(flowBody.steps)) {
434
+ this.error(
435
+ `[@ossy/app][build] flow entry ${entryInfo.sourcePath} must default-export an object with steps[]`,
436
+ )
437
+ }
438
+ if (seenFlowIds.has(flowId)) {
439
+ this.error(`[@ossy/app][build] Duplicate flow id "${flowId}"`)
440
+ }
441
+ seenFlowIds.add(flowId)
442
+ flows.push({
443
+ id: flowId,
444
+ feature: typeof feature === 'string' ? feature : '',
445
+ requires: Array.isArray(requires) ? requires : [],
446
+ entry: url,
447
+ package: entryPackage(entryInfo, appPackageName),
448
+ })
449
+ continue
450
+ }
451
+
347
452
  // Layouts wrap every page render. The bundle exports `id` and a default
348
453
  // export (the React component). Only one `*.layout.jsx` per app is
349
454
  // supported. They don't produce routable entries; they accumulate into
@@ -400,27 +505,51 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
400
505
  continue
401
506
  }
402
507
 
403
- // Resources are pure data — the default export *is* the template.
404
- // They don't use `metadata`, don't need a derived id, and don't
405
- // produce a routable manifest entry; they accumulate into a separate
406
- // `resourceTemplates` array on the manifest.
407
- if (entryInfo.kind === 'resource') {
408
- const template = mod && mod.default
409
- if (!template || typeof template !== 'object' || Array.isArray(template)) {
508
+ if (entryInfo.kind === 'schema') {
509
+ const schema = mod && mod.default
510
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
410
511
  this.error(
411
- `[@ossy/app][build] resource entry ${entryInfo.sourcePath} must default-export an object`,
512
+ `[@ossy/app][build] schema entry ${entryInfo.sourcePath} must default-export an object`,
412
513
  )
413
514
  }
414
- if (typeof template.id !== 'string' || template.id.trim() === '') {
515
+ if (typeof schema.id !== 'string' || schema.id.trim() === '') {
415
516
  this.error(
416
- `[@ossy/app][build] resource entry ${entryInfo.sourcePath} default export is missing a non-empty string "id"`,
517
+ `[@ossy/app][build] schema entry ${entryInfo.sourcePath} default export is missing a non-empty string "id"`,
417
518
  )
418
519
  }
419
- if (seenResourceIds.has(template.id)) {
420
- this.error(`[@ossy/app][build] Duplicate resource template id "${template.id}"`)
520
+ if (!isCanonicalSchemaId(schema.id)) {
521
+ this.error(
522
+ `[@ossy/app][build] schema entry ${entryInfo.sourcePath} id must match @{provider}/{feature}/schema/{concept} — got "${schema.id}"`,
523
+ )
421
524
  }
422
- seenResourceIds.add(template.id)
423
- resourceTemplates.push({ ...template, package: entryPackage(entryInfo, appPackageName) })
525
+ if (seenResourceIds.has(schema.id)) {
526
+ this.error(`[@ossy/app][build] Duplicate schema id "${schema.id}"`)
527
+ }
528
+ seenResourceIds.add(schema.id)
529
+ schemas.push({ ...schema, package: entryPackage(entryInfo, appPackageName) })
530
+ continue
531
+ }
532
+
533
+ if (entryInfo.kind === 'task') {
534
+ const taskMeta = mod?.metadata
535
+ const taskId = taskMeta?.id
536
+ if (typeof taskId !== 'string' || taskId.trim() === '') {
537
+ this.error(
538
+ `[@ossy/app][build] task entry ${entryInfo.sourcePath} must export a non-empty string "metadata.id"`,
539
+ )
540
+ }
541
+ if (isActionId(taskId)) {
542
+ this.error(
543
+ `[@ossy/app][build] Task "${taskId}" uses /actions/ — use /tasks/ (e.g. "${taskIdFromActionId(taskId)}")`,
544
+ )
545
+ }
546
+ if (seenIds.task.has(taskId)) {
547
+ this.error(`[@ossy/app][build] Duplicate task id "${taskId}"`)
548
+ }
549
+ seenIds.task.add(taskId)
550
+ const pkg = entryPackage(entryInfo, appPackageName)
551
+ taskPending.push({ mod, entryInfo, url, pkg })
552
+ entries.push({ type: 'task', id: taskId, entry: url, package: pkg })
424
553
  continue
425
554
  }
426
555
 
@@ -438,7 +567,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
438
567
  if (mod.slot != null) {
439
568
  this.error(
440
569
  `[@ossy/app][build] component ${entryInfo.sourcePath} must not export \`slot\`. ` +
441
- 'Assign components to shell slots in `*.layout.jsx` via `export const slots`.',
570
+ 'Assign components to app slots in `*.layout.jsx` via `export const slots`.',
442
571
  )
443
572
  }
444
573
  components.push({ id, entry: url, package: entryPackage(entryInfo, appPackageName) })
@@ -447,6 +576,19 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
447
576
 
448
577
  if (entryInfo.kind === 'page') {
449
578
  const pagePath = rawMeta.path !== undefined ? rawMeta.path : defaultPageRoute(id)
579
+ reportValidationError(
580
+ this,
581
+ Schema.of({ schemas: [] }).validate(
582
+ CAPABILITY_SCHEMA_ID.PAGE_META,
583
+ {
584
+ id,
585
+ path: pagePath,
586
+ ...(rawMeta.title != null ? { title: rawMeta.title } : {}),
587
+ },
588
+ { sourcePath: entryInfo.sourcePath },
589
+ ),
590
+ entryInfo.sourcePath,
591
+ )
450
592
  entries.push({
451
593
  type: 'page',
452
594
  id,
@@ -467,12 +609,74 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
467
609
  `Add a \`path\` to the metadata or default-export object (e.g. \`path: '/api/${id}'\`).`,
468
610
  )
469
611
  }
470
- entries.push({ type: 'api', id, path: rawMeta.path, entry: url, package: entryPackage(entryInfo, appPackageName) })
471
- } else {
472
- entries.push({ type: 'task', id, entry: url, package: entryPackage(entryInfo, appPackageName) })
612
+ const apiEntry = {
613
+ type: 'api',
614
+ id,
615
+ path: rawMeta.path,
616
+ entry: url,
617
+ package: entryPackage(entryInfo, appPackageName),
618
+ }
619
+ if (typeof rawMeta.action === 'string' && rawMeta.action.trim()) {
620
+ apiEntry.action = rawMeta.action
621
+ apiEntry.method = rawMeta.method ?? 'GET'
622
+ if (Array.isArray(rawMeta.query)) apiEntry.query = rawMeta.query
623
+ actionRoutes[rawMeta.action] = {
624
+ path: rawMeta.path,
625
+ method: rawMeta.method ?? 'GET',
626
+ ...(Array.isArray(rawMeta.query) && rawMeta.query.length ? { query: rawMeta.query } : {}),
627
+ }
628
+ }
629
+ entries.push(apiEntry)
473
630
  }
474
631
  }
475
632
 
633
+ const actionIdSet = new Set(actions.map((a) => a.id))
634
+ const schemaIdSet = new Set(schemas.map((s) => s.id))
635
+ const taskIdList = taskPending.map(({ mod }) => mod?.metadata?.id).filter(Boolean)
636
+
637
+ const schemaEngine = Schema.of(
638
+ { schemas },
639
+ { actionIds: [...actionIdSet], taskIds: taskIdList, strictRefs: true },
640
+ )
641
+
642
+ for (const { mod, entryInfo } of taskPending) {
643
+ reportValidationError(
644
+ this,
645
+ schemaEngine.validate(CAPABILITY_SCHEMA_ID.TASK_META, mod?.metadata ?? {}, {
646
+ strictRefs: true,
647
+ schemaIds: schemaIdSet,
648
+ sourcePath: entryInfo.sourcePath,
649
+ }),
650
+ entryInfo.sourcePath,
651
+ )
652
+ }
653
+
654
+ const taskCatalog = taskPending.map(({ mod, url, pkg }) =>
655
+ extractTaskCatalogEntry({
656
+ metadata: mod?.metadata ?? {},
657
+ package: pkg,
658
+ entry: url,
659
+ actionIdsInManifest: actionIdSet,
660
+ }),
661
+ )
662
+
663
+ for (const entry of taskCatalog) {
664
+ reportValidationError(
665
+ this,
666
+ schemaEngine.validate(CAPABILITY_SCHEMA_ID.TASK_CAPABILITY, entry, {
667
+ strictRefs: true,
668
+ schemaIds: schemaIdSet,
669
+ actionIds: actionIdSet,
670
+ allowDerived: true,
671
+ allowUnknownKeys: true,
672
+ label: entry.id,
673
+ }),
674
+ entry.id,
675
+ )
676
+ }
677
+
678
+ const taskGraphEdges = buildTaskCatalogEdges(taskCatalog)
679
+
476
680
  if (layouts.length > 1) {
477
681
  this.error(
478
682
  `[@ossy/app][build] Only one *.layout.jsx per app is allowed (found ${layouts.length}). ` +
@@ -488,19 +692,40 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
488
692
  const manifest = {
489
693
  entries,
490
694
  components,
491
- resourceTemplates,
695
+ schemas,
492
696
  aggregates,
493
697
  integrations,
494
698
  startups,
495
699
  emails,
496
700
  actions,
701
+ forms,
497
702
  e2es,
703
+ flows,
498
704
  layouts,
499
705
  definitions,
500
706
  config: configValue || {},
707
+ actionRoutes,
708
+ taskCatalog,
709
+ taskGraphEdges,
501
710
  }
502
711
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
503
712
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
713
+
714
+ const buildDirPath = path.dirname(manifestPath)
715
+ const tasks = taskCatalog.map((t) => ({ id: t.id }))
716
+ const capabilities = buildCapabilities({
717
+ actions,
718
+ tasks,
719
+ schemas,
720
+ taskCatalog,
721
+ taskGraphEdges,
722
+ })
723
+ const capabilitiesPath = path.join(buildDirPath, 'capabilities.json')
724
+ fs.writeFileSync(capabilitiesPath, JSON.stringify(capabilities, null, 2), 'utf8')
725
+
726
+ const actionsSchemaPath = path.join(buildDirPath, 'actions.schema.json')
727
+ const actionsSchema = buildActionsSchema(actions, { schemas, tasks })
728
+ fs.writeFileSync(actionsSchemaPath, JSON.stringify(actionsSchema, null, 2), 'utf8')
504
729
  },
505
730
  }
506
731
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.40.2",
3
+ "version": "1.40.3",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -9,10 +9,12 @@
9
9
  ".": "./src/index.js",
10
10
  "./shell": "./src/shell/index.js",
11
11
  "./runtime/page-runtime": "./runtime/page-runtime.js",
12
- "./runtime/resolve-shell-slots": "./runtime/resolve-shell-slots.js",
12
+ "./runtime/resolve-app-slots": "./runtime/resolve-app-slots.js",
13
13
  "./runtime/api-runtime": "./runtime/api-runtime.js",
14
14
  "./runtime/task-runtime": "./runtime/task-runtime.js",
15
15
  "./manifest/build-manifest-summary": "./src/manifest/build-manifest-summary.js",
16
+ "./manifest/build-actions-schema": "./src/manifest/build-actions-schema.js",
17
+ "./manifest/build-capabilities": "./src/manifest/build-capabilities.js",
16
18
  "./manifest/discover-package-definitions": "./src/manifest/discover-package-definitions.js",
17
19
  "./manifest/serialize-package-definition": "./src/manifest/serialize-package-definition.js"
18
20
  },
@@ -42,15 +44,17 @@
42
44
  "@babel/eslint-parser": "^7.15.8",
43
45
  "@babel/preset-react": "^7.26.3",
44
46
  "@babel/register": "^7.25.9",
45
- "@ossy/design-system": "^1.40.2",
46
- "@ossy/locale": "^1.40.2",
47
+ "@ossy/design-system": "^1.40.3",
48
+ "@ossy/locale": "^1.40.3",
47
49
  "@ossy/pages": "^1.23.0",
48
- "@ossy/platform": "^1.39.2",
49
- "@ossy/router": "^1.40.2",
50
- "@ossy/router-react": "^1.40.2",
51
- "@ossy/sdk": "^1.40.2",
52
- "@ossy/sdk-react": "^1.40.2",
53
- "@ossy/themes": "^1.40.2",
50
+ "@ossy/platform": "^1.39.3",
51
+ "@ossy/router": "^1.40.3",
52
+ "@ossy/router-react": "^1.40.3",
53
+ "@ossy/schema": "^1.0.1",
54
+ "@ossy/sdk": "^1.40.3",
55
+ "@ossy/sdk-react": "^1.40.3",
56
+ "@ossy/themes": "^1.40.3",
57
+ "@ossy/workspaces": "^1.17.3",
54
58
  "@rollup/plugin-alias": "^6.0.0",
55
59
  "@rollup/plugin-babel": "^7.0.0",
56
60
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -84,5 +88,5 @@
84
88
  "README.md",
85
89
  "tsconfig.json"
86
90
  ],
87
- "gitHead": "2b8745d57fee8b6c08787e2755b4df489291a5cd"
91
+ "gitHead": "a0d89185a17f8de8ce328c3a648c108ff1d61d8f"
88
92
  }
@@ -1,7 +1,7 @@
1
1
  import { createElement } from 'react'
2
2
  import { pageIdToDocumentTitleKey, resolveMessage } from '@ossy/locale'
3
3
  import { App } from '../src/shell/App.jsx'
4
- import { resolvePageSlots } from './resolve-shell-slots.js'
4
+ import { resolvePageSlots } from './resolve-app-slots.js'
5
5
 
6
6
  /**
7
7
  * Dynamically imports each component bundle listed in `entries` and returns a
@@ -129,10 +129,11 @@ export function createPageEntry (pageModule, options = {}) {
129
129
 
130
130
  const { Layout = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
131
131
  const componentsById = await loadComponents(componentEntries)
132
+ const PageContent = (slotProps = {}) => createElement(Component, { ...pageProps, ...slotProps })
132
133
  const components = resolvePageSlots({
133
134
  layoutSlots,
134
135
  componentsById,
135
- pageComponent: Component,
136
+ pageComponent: PageContent,
136
137
  })
137
138
  const tree = buildTree({ Component, Layout, metadata, props: { ...pageProps, components } })
138
139
  const bootstrapUrl = toBootstrapUrl(entryUrl)
@@ -175,10 +176,11 @@ export function createPageEntry (pageModule, options = {}) {
175
176
  loadComponents(componentEntries),
176
177
  loadLayout(layoutEntry),
177
178
  ]).then(([{ hydrateRoot }, componentsById, Layout]) => {
179
+ const PageContent = (slotProps = {}) => createElement(Component, { ...pageProps, ...slotProps })
178
180
  const components = resolvePageSlots({
179
181
  layoutSlots,
180
182
  componentsById,
181
- pageComponent: Component,
183
+ pageComponent: PageContent,
182
184
  })
183
185
  hydrateRoot(document, buildTree({ Component, Layout, metadata, props: { ...pageProps, components } }))
184
186
  }).catch((err) => {
@@ -0,0 +1,89 @@
1
+ /** Canonical app chrome slot names (namespaced). App layout maps chrome only — not content. */
2
+ export const APP_SLOT_NAMES = [
3
+ 'app:header',
4
+ 'app:sidebar',
5
+ 'app:toolbar',
6
+ 'app:notifications',
7
+ 'app:system-messages',
8
+ ]
9
+
10
+ /** Platform-owned slot filled with the current route page component. */
11
+ export const CONTENT_SLOT_NAME = 'app:content'
12
+
13
+ const APP_SLOT_PREFIX = 'app'
14
+
15
+ /** Bare app region → namespaced key (legacy fallback for app components only). */
16
+ const BARE_APP_REGION = {
17
+ header: 'app:header',
18
+ sidebar: 'app:sidebar',
19
+ toolbar: 'app:toolbar',
20
+ notifications: 'app:notifications',
21
+ 'system-messages': 'app:system-messages',
22
+ content: CONTENT_SLOT_NAME,
23
+ }
24
+
25
+ /**
26
+ * Normalize a slot map key to the canonical namespaced form when it is a bare app region.
27
+ *
28
+ * @param {string} slotName
29
+ * @returns {string}
30
+ */
31
+ export function normalizeAppSlotName (slotName) {
32
+ const key = typeof slotName === 'string' ? slotName.trim() : ''
33
+ return BARE_APP_REGION[key] ?? key
34
+ }
35
+
36
+ /**
37
+ * Build `Record<slotName, Component>` from the app layout's static `slots` map
38
+ * (`slotName → componentId`) and components loaded by `metadata.id`.
39
+ *
40
+ * @param {Record<string, string> | null | undefined} layoutSlotsMap
41
+ * @param {Record<string, import('react').ComponentType>} componentsById
42
+ * @returns {Record<string, import('react').ComponentType>}
43
+ */
44
+ export function resolveAppSlots (layoutSlotsMap, componentsById) {
45
+ /** @type {Record<string, import('react').ComponentType>} */
46
+ const resolved = {}
47
+
48
+ const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
49
+ for (const [rawSlot, componentId] of Object.entries(map)) {
50
+ const slotName = normalizeAppSlotName(rawSlot)
51
+ const id = typeof componentId === 'string' ? componentId.trim() : ''
52
+ if (!slotName || !id || slotName === CONTENT_SLOT_NAME) continue
53
+ const Component = componentsById[id]
54
+ if (Component) resolved[slotName] = Component
55
+ }
56
+
57
+ for (const slotName of APP_SLOT_NAMES) {
58
+ if (resolved[slotName]) continue
59
+ if (componentsById[slotName]) {
60
+ resolved[slotName] = componentsById[slotName]
61
+ continue
62
+ }
63
+ const bare = slotName.slice(`${APP_SLOT_PREFIX}:`.length)
64
+ if (componentsById[bare]) resolved[slotName] = componentsById[bare]
65
+ }
66
+
67
+ return resolved
68
+ }
69
+
70
+ /**
71
+ * Full provider slot map for a page request: app chrome and page content.
72
+ * Feature components register at canonical ADR 0006 ids (`@ossy/…/view|form/…`).
73
+ *
74
+ * @param {{
75
+ * layoutSlots?: Record<string, string> | null,
76
+ * componentsById?: Record<string, import('react').ComponentType>,
77
+ * pageComponent?: import('react').ComponentType | null,
78
+ * }} options
79
+ * @returns {Record<string, import('react').ComponentType>}
80
+ */
81
+ export function resolvePageSlots ({ layoutSlots, componentsById = {}, pageComponent = null } = {}) {
82
+ const appSlots = resolveAppSlots(layoutSlots, componentsById)
83
+ /** @type {Record<string, import('react').ComponentType>} */
84
+ const resolved = { ...componentsById, ...appSlots }
85
+ if (pageComponent) {
86
+ resolved[CONTENT_SLOT_NAME] = pageComponent
87
+ }
88
+ return resolved
89
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Derive MCP tool name from platform action id.
3
+ *
4
+ * @example
5
+ * actionIdToToolName('@ossy/resources/actions/list') // 'ossy_resources_list'
6
+ *
7
+ * @param {string} actionId
8
+ * @returns {string}
9
+ */
10
+ export function actionIdToToolName (actionId) {
11
+ const parts = actionId.split('/')
12
+ if (parts.length >= 4 && parts[0].startsWith('@') && parts[2] === 'actions') {
13
+ const provider = parts[0].slice(1)
14
+ return `${provider}_${parts[1]}_${parts[3]}`.replace(/-/g, '_')
15
+ }
16
+ return `ossy_${actionId.replace(/^@/, '').replace(/\//g, '_').replace(/-/g, '_')}`
17
+ }
18
+
19
+ /**
20
+ * @param {string} actionId
21
+ * @returns {string}
22
+ */
23
+ export function humanizeActionId (actionId) {
24
+ const intent = actionId.split('/').pop() || actionId
25
+ return intent
26
+ .split('-')
27
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
28
+ .join(' ')
29
+ }