@ossy/app 1.40.3 → 3.0.2

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.
Files changed (40) hide show
  1. package/README.md +6 -0
  2. package/cli/get-platform-files.task.js +4 -6
  3. package/cli/manifest-plugin.js +111 -36
  4. package/package.json +23 -14
  5. package/runtime/merge-shell-slots.js +148 -0
  6. package/runtime/page-runtime.js +32 -16
  7. package/runtime/resolve-app-slots.js +48 -11
  8. package/src/en.translations.json +8 -0
  9. package/src/manifest/build-actions-schema.js +1 -36
  10. package/src/manifest/build-capabilities.js +1 -190
  11. package/src/manifest/build-manifest-summary.js +1 -121
  12. package/src/manifest/discover-package-definitions.js +1 -100
  13. package/src/manifest/resolve-page-layout.js +51 -0
  14. package/src/manifest/serialize-package-definition.js +1 -30
  15. package/src/shell/App.jsx +13 -7
  16. package/src/shell/AppSettings.jsx +6 -0
  17. package/src/shell/DevPagesPanel.jsx +1 -1
  18. package/src/shell/HeaderAuthActions.jsx +49 -0
  19. package/src/shell/LanguageList.jsx +83 -0
  20. package/src/shell/LanguageSelect.jsx +73 -0
  21. package/src/shell/ThemeEditor.jsx +2 -2
  22. package/src/shell/ThemeSelect.jsx +101 -0
  23. package/src/shell/buildSidebarNav.js +86 -0
  24. package/src/shell/index.js +7 -0
  25. package/src/shell/languageCode.js +31 -0
  26. package/src/shell/resolvePackageHomePageId.js +14 -0
  27. package/src/shell/useCompactShellLayout.js +24 -0
  28. package/src/shell-registry/blank.layout.jsx +8 -0
  29. package/src/shell-registry/default.layout.jsx +101 -0
  30. package/src/shell-registry/footer-default.component.jsx +39 -0
  31. package/src/shell-registry/head-default.component.jsx +17 -0
  32. package/src/shell-registry/header-default.component.jsx +50 -0
  33. package/src/shell-registry/logo-logomark.component.jsx +8 -0
  34. package/src/shell-registry/logo-logotype.component.jsx +15 -0
  35. package/src/shell-registry/minimal.layout.jsx +58 -0
  36. package/src/shell-registry/sidebar-default.component.jsx +281 -0
  37. package/src/sv.translations.json +8 -0
  38. package/src/manifest/action-id-to-tool-name.js +0 -29
  39. package/src/manifest/build-action-input-schema.js +0 -220
  40. package/src/manifest/template-to-json-schema.js +0 -103
package/README.md CHANGED
@@ -70,6 +70,12 @@ export default {
70
70
  }
71
71
  ```
72
72
 
73
+ Optional flags:
74
+
75
+ | Key | Default | Purpose |
76
+ |-----|---------|---------|
77
+ | `enablePushInvalidation` | `false` | App-wide SSE cache invalidation. Prefer `<PushInvalidationSubscriber />` on specific pages instead — see [@ossy/sdk-react README](../sdk-react/README.md#push-invalidation). |
78
+
73
79
  Config is loaded at build time and merged with request-time settings (e.g. user theme preference from cookies).
74
80
 
75
81
  ## API routes
@@ -40,7 +40,7 @@ export const TRANSLATIONS_FILE_PATTERN = /\.translations\.json$/
40
40
  * @property {PlatformEntry[]} forms Discovered `*.form.{js,mjs,cjs}` entries.
41
41
  * @property {PlatformEntry[]} e2es Discovered `*.e2e.{js,mjs,cjs}` entries (installed packages only).
42
42
  * @property {PlatformEntry[]} flows Discovered `*.flow.{js,mjs,cjs}` entries (installed packages only).
43
- * @property {PlatformEntry[]} layouts Discovered `*.layout.{jsx,tsx,...}` entries (app root `src/` only — never from installed packages).
43
+ * @property {PlatformEntry[]} layouts Discovered `*.layout.{jsx,tsx,...}` entries (app and installed packages).
44
44
  */
45
45
  export function discoverFilesByPattern (srcDir, filePattern) {
46
46
  const dir = path.resolve(srcDir)
@@ -183,8 +183,7 @@ export function discoverInstalledPackageEntries (projectRoot) {
183
183
  ...discoverFilesByPattern(packageSrcDir, FORM_FILE_PATTERN),
184
184
  ...discoverFilesByPattern(packageSrcDir, E2E_FILE_PATTERN),
185
185
  ...discoverFilesByPattern(packageSrcDir, FLOW_FILE_PATTERN),
186
- // Layouts are intentionally excluded: *.layout.jsx is an app-level
187
- // primitive only. Feature packages must never define layouts.
186
+ ...discoverFilesByPattern(packageSrcDir, LAYOUT_FILE_PATTERN),
188
187
  ]
189
188
  for (const sourcePath of files) {
190
189
  const stat = fs.statSync(sourcePath)
@@ -237,9 +236,8 @@ export default async function getPlatformFiles (srcDir) {
237
236
  // from the local `src/` — to avoid pulling in per-project duplicates of the
238
237
  // same aggregate class or test suite that is canonically owned by the feature
239
238
  // package.
240
- // Layouts are the opposite restriction: they are only discovered from the
241
- // app's own `src/`, never from installed packages. Feature packages are pure
242
- // content (pages, actions, tasks, etc.) and must never define layouts.
239
+ // Layouts are discovered from the app's `src/` and from installed packages
240
+ // (`@ossy/app` ships default layouts via `ossy.src`).
243
241
  const localFiles = [
244
242
  ...discoverFilesByPattern(srcDir, PAGE_FILE_PATTERN),
245
243
  ...discoverFilesByPattern(srcDir, API_FILE_PATTERN),
@@ -3,9 +3,9 @@ import path from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
 
5
5
  import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.js'
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'
6
+ import { discoverPackageDefinitions } from '@ossy/manifest/discover-package-definitions'
7
+ import { buildActionsSchema } from '@ossy/manifest/build-actions-schema'
8
+ import { buildCapabilities } from '@ossy/manifest/build-capabilities'
9
9
  import { extractTaskCatalogEntry, buildTaskCatalogEdges } from '../src/manifest/extract-task-catalog.js'
10
10
  import {
11
11
  Schema,
@@ -14,6 +14,11 @@ import {
14
14
  CAPABILITY_SCHEMA_ID,
15
15
  } from '@ossy/schema'
16
16
  import { isCanonicalSchemaId } from '@ossy/schema'
17
+ import {
18
+ resolvePageLayoutId,
19
+ resolvePageShellSlots,
20
+ isCanonicalLayoutId,
21
+ } from '../src/manifest/resolve-page-layout.js'
17
22
 
18
23
  /**
19
24
  * @typedef {import('./get-platform-files.task.js').PlatformEntry} PlatformEntry
@@ -86,7 +91,7 @@ import { isCanonicalSchemaId } from '@ossy/schema'
86
91
  * @property {string} entry URL the platform serves the form bundle from.
87
92
  *
88
93
  * @typedef {object} LayoutManifestEntry
89
- * @property {string} id Unique layout id (e.g. `'app-shell'`).
94
+ * @property {string} id Canonical layout id (e.g. `'@ossy/app/layout/default'`).
90
95
  * @property {string} entry URL the platform serves the layout bundle from.
91
96
  * @property {Record<string, string>} [slots]
92
97
  * App-controlled slot map (`slotName` → component `metadata.id`), from `export const slots` in `*.layout.jsx`.
@@ -164,6 +169,17 @@ function entryPackage (entryInfo, appPackageName) {
164
169
  return entryInfo.packageName || appPackageName
165
170
  }
166
171
 
172
+ /** Repo-relative path for Storybook / dev tooling (Vite source imports). */
173
+ function manifestSourcePath (entryInfo, projectRoot) {
174
+ if (!entryInfo?.sourcePath || !projectRoot) return undefined
175
+ return path.relative(projectRoot, entryInfo.sourcePath).split(path.sep).join('/')
176
+ }
177
+
178
+ function withSourcePath (obj, entryInfo, projectRoot) {
179
+ const sourcePath = manifestSourcePath(entryInfo, projectRoot)
180
+ return sourcePath ? { ...obj, sourcePath } : obj
181
+ }
182
+
167
183
  export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configValue, appPackageName = '@ossy/app', manifestPath, projectRoot }) {
168
184
  return {
169
185
  name: 'ossy-manifest',
@@ -205,6 +221,8 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
205
221
  /** @type {LayoutManifestEntry[]} */
206
222
  const layouts = []
207
223
  const seenLayoutIds = new Set()
224
+ /** @type {Map<string, { rawMeta: object, packageName: string }>} */
225
+ const pageMetaById = new Map()
208
226
  /** @type {Array<{ mod: object, entryInfo: import('./get-platform-files.task.js').PlatformEntry, url: string, pkg: string }>} */
209
227
  const taskPending = []
210
228
  for (const fileName of Object.keys(bundle)) {
@@ -256,7 +274,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
256
274
  this.error(`[@ossy/app][build] Duplicate integration id "${integrationId}"`)
257
275
  }
258
276
  seenIntegrationIds.add(integrationId)
259
- integrations.push({ id: integrationId, entry: url, credentials, package: entryPackage(entryInfo, appPackageName) })
277
+ integrations.push(withSourcePath({ id: integrationId, entry: url, credentials, package: entryPackage(entryInfo, appPackageName) }, entryInfo, projectRoot))
260
278
  continue
261
279
  }
262
280
 
@@ -279,7 +297,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
279
297
  this.error(`[@ossy/app][build] Duplicate startup id "${startupId}"`)
280
298
  }
281
299
  seenStartupIds.add(startupId)
282
- startups.push({ id: startupId, entry: url, package: entryPackage(entryInfo, appPackageName) })
300
+ startups.push(withSourcePath({ id: startupId, entry: url, package: entryPackage(entryInfo, appPackageName) }, entryInfo, projectRoot))
283
301
  continue
284
302
  }
285
303
 
@@ -303,7 +321,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
303
321
  this.error(`[@ossy/app][build] Duplicate email id "${emailId}"`)
304
322
  }
305
323
  seenEmailIds.add(emailId)
306
- emails.push({ id: emailId, entry: url, package: entryPackage(entryInfo, appPackageName) })
324
+ emails.push(withSourcePath({ id: emailId, entry: url, package: entryPackage(entryInfo, appPackageName) }, entryInfo, projectRoot))
307
325
  continue
308
326
  }
309
327
 
@@ -330,7 +348,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
330
348
  entryInfo.sourcePath,
331
349
  )
332
350
  seenActionIds.add(actionId)
333
- actions.push({ id: actionId, entry: url, access, package: entryPackage(entryInfo, appPackageName) })
351
+ actions.push(withSourcePath({ id: actionId, entry: url, access, package: entryPackage(entryInfo, appPackageName) }, entryInfo, projectRoot))
334
352
  continue
335
353
  }
336
354
 
@@ -352,12 +370,12 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
352
370
  this.error(`[@ossy/app][build] Duplicate form id "${formId}"`)
353
371
  }
354
372
  seenFormIds.add(formId)
355
- forms.push({
373
+ forms.push(withSourcePath({
356
374
  id: formId,
357
375
  schemaId,
358
376
  entry: url,
359
377
  package: entryPackage(entryInfo, appPackageName),
360
- })
378
+ }, entryInfo, projectRoot))
361
379
  continue
362
380
  }
363
381
 
@@ -381,7 +399,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
381
399
  this.error(`[@ossy/app][build] Duplicate aggregate id "${aggregateId}"`)
382
400
  }
383
401
  seenAggregateIds.add(aggregateId)
384
- aggregates.push({ id: aggregateId, entry: url, package: entryPackage(entryInfo, appPackageName) })
402
+ aggregates.push(withSourcePath({ id: aggregateId, entry: url, package: entryPackage(entryInfo, appPackageName) }, entryInfo, projectRoot))
385
403
  continue
386
404
  }
387
405
 
@@ -409,13 +427,13 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
409
427
  this.error(`[@ossy/app][build] Duplicate e2e id "${e2eId}"`)
410
428
  }
411
429
  seenE2eIds.add(e2eId)
412
- e2es.push({
430
+ e2es.push(withSourcePath({
413
431
  id: e2eId,
414
432
  feature: typeof feature === 'string' ? feature : '',
415
433
  requires: Array.isArray(requires) ? requires : [],
416
434
  entry: url,
417
435
  package: entryPackage(entryInfo, appPackageName),
418
- })
436
+ }, entryInfo, projectRoot))
419
437
  continue
420
438
  }
421
439
 
@@ -439,26 +457,36 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
439
457
  this.error(`[@ossy/app][build] Duplicate flow id "${flowId}"`)
440
458
  }
441
459
  seenFlowIds.add(flowId)
442
- flows.push({
460
+ flows.push(withSourcePath({
443
461
  id: flowId,
444
462
  feature: typeof feature === 'string' ? feature : '',
445
463
  requires: Array.isArray(requires) ? requires : [],
446
464
  entry: url,
447
465
  package: entryPackage(entryInfo, appPackageName),
448
- })
466
+ }, entryInfo, projectRoot))
449
467
  continue
450
468
  }
451
469
 
452
- // Layouts wrap every page render. The bundle exports `id` and a default
453
- // export (the React component). Only one `*.layout.jsx` per app is
454
- // supported. They don't produce routable entries; they accumulate into
455
- // `manifest.layouts` so the server can inject them at render time.
470
+ // Layouts wrap page renders. The bundle exports metadata.id, optional slots,
471
+ // and a default export (the React component). They accumulate into
472
+ // `manifest.layouts` so the server can inject them at render time per page.
456
473
  if (entryInfo.kind === 'layout') {
457
- const layoutId = mod && mod.id
474
+ const layoutMeta = mod?.metadata ?? {}
475
+ const layoutId =
476
+ typeof layoutMeta.id === 'string' && layoutMeta.id.trim()
477
+ ? layoutMeta.id.trim()
478
+ : typeof mod?.id === 'string'
479
+ ? mod.id.trim()
480
+ : ''
458
481
  const component = mod && mod.default
459
- if (typeof layoutId !== 'string' || layoutId.trim() === '') {
482
+ if (!layoutId) {
460
483
  this.error(
461
- `[@ossy/app][build] layout entry ${entryInfo.sourcePath} must export a non-empty string "id"`,
484
+ `[@ossy/app][build] layout entry ${entryInfo.sourcePath} must export metadata.id (canonical layout id)`,
485
+ )
486
+ }
487
+ if (!isCanonicalLayoutId(layoutId)) {
488
+ this.error(
489
+ `[@ossy/app][build] layout entry ${entryInfo.sourcePath} id must match @{provider}/{feature}/layout/{concept} — got "${layoutId}"`,
462
490
  )
463
491
  }
464
492
  if (typeof component !== 'function') {
@@ -496,12 +524,12 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
496
524
  layoutSlots[sk] = cid
497
525
  }
498
526
  }
499
- layouts.push({
527
+ layouts.push(withSourcePath({
500
528
  id: layoutId,
501
529
  entry: url,
502
530
  package: entryPackage(entryInfo, appPackageName),
503
531
  ...(layoutSlots && Object.keys(layoutSlots).length > 0 ? { slots: layoutSlots } : {}),
504
- })
532
+ }, entryInfo, projectRoot))
505
533
  continue
506
534
  }
507
535
 
@@ -526,7 +554,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
526
554
  this.error(`[@ossy/app][build] Duplicate schema id "${schema.id}"`)
527
555
  }
528
556
  seenResourceIds.add(schema.id)
529
- schemas.push({ ...schema, package: entryPackage(entryInfo, appPackageName) })
557
+ schemas.push(withSourcePath({ ...schema, package: entryPackage(entryInfo, appPackageName) }, entryInfo, projectRoot))
530
558
  continue
531
559
  }
532
560
 
@@ -549,7 +577,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
549
577
  seenIds.task.add(taskId)
550
578
  const pkg = entryPackage(entryInfo, appPackageName)
551
579
  taskPending.push({ mod, entryInfo, url, pkg })
552
- entries.push({ type: 'task', id: taskId, entry: url, package: pkg })
580
+ entries.push(withSourcePath({ type: 'task', id: taskId, entry: url, package: pkg }, entryInfo, projectRoot))
553
581
  continue
554
582
  }
555
583
 
@@ -559,6 +587,14 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
559
587
  this.error(`[@ossy/app][build] ${entryInfo.kind} entry missing metadata.id: ${entryInfo.sourcePath}`)
560
588
  }
561
589
  if (seenIds[entryInfo.kind].has(id)) {
590
+ // App-local components are discovered first. Allow the app to shadow
591
+ // the same component id from an installed package (e.g. brand logos).
592
+ if (entryInfo.kind === 'component' && entryInfo.packageName) {
593
+ console.warn(
594
+ `[@ossy/app][build] Skipping package component "${id}" from ${entryInfo.packageName} — app already provides it`,
595
+ )
596
+ continue
597
+ }
562
598
  this.error(`[@ossy/app][build] Duplicate ${entryInfo.kind} id "${id}"`)
563
599
  }
564
600
  seenIds[entryInfo.kind].add(id)
@@ -570,7 +606,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
570
606
  'Assign components to app slots in `*.layout.jsx` via `export const slots`.',
571
607
  )
572
608
  }
573
- components.push({ id, entry: url, package: entryPackage(entryInfo, appPackageName) })
609
+ components.push(withSourcePath({ id, entry: url, package: entryPackage(entryInfo, appPackageName) }, entryInfo, projectRoot))
574
610
  continue
575
611
  }
576
612
 
@@ -589,13 +625,17 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
589
625
  ),
590
626
  entryInfo.sourcePath,
591
627
  )
592
- entries.push({
628
+ entries.push(withSourcePath({
593
629
  type: 'page',
594
630
  id,
595
631
  path: pagePath,
596
632
  title: rawMeta.title,
597
633
  entry: url,
598
634
  package: entryPackage(entryInfo, appPackageName),
635
+ }, entryInfo, projectRoot))
636
+ pageMetaById.set(id, {
637
+ rawMeta,
638
+ packageName: entryPackage(entryInfo, appPackageName),
599
639
  })
600
640
  } else if (entryInfo.kind === 'api') {
601
641
  // APIs have no sensible default route (unlike pages where we can
@@ -626,7 +666,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
626
666
  ...(Array.isArray(rawMeta.query) && rawMeta.query.length ? { query: rawMeta.query } : {}),
627
667
  }
628
668
  }
629
- entries.push(apiEntry)
669
+ entries.push(withSourcePath(apiEntry, entryInfo, projectRoot))
630
670
  }
631
671
  }
632
672
 
@@ -677,18 +717,53 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
677
717
 
678
718
  const taskGraphEdges = buildTaskCatalogEdges(taskCatalog)
679
719
 
680
- if (layouts.length > 1) {
681
- this.error(
682
- `[@ossy/app][build] Only one *.layout.jsx per app is allowed (found ${layouts.length}). ` +
683
- 'Use conditional rendering inside the app layout for route-specific chrome.',
684
- )
685
- }
686
-
687
720
  /** @type {Manifest} */
688
721
  const definitions = projectRoot
689
722
  ? await discoverPackageDefinitions(projectRoot)
690
723
  : {}
691
724
 
725
+ const layoutIdSet = new Set(layouts.map((layout) => layout.id))
726
+ const componentIdSet = new Set(components.map((component) => component.id))
727
+
728
+ for (const entry of entries) {
729
+ if (entry.type !== 'page') continue
730
+ const meta = pageMetaById.get(entry.id)
731
+ const layoutId = resolvePageLayoutId({
732
+ pageMetadata: meta?.rawMeta ?? {},
733
+ appConfig: configValue ?? {},
734
+ })
735
+ if (!layoutIdSet.has(layoutId)) {
736
+ this.error(
737
+ `[@ossy/app][build] page "${entry.id}" references layout "${layoutId}" which is not in manifest.layouts`,
738
+ )
739
+ }
740
+ if (!isCanonicalLayoutId(layoutId)) {
741
+ this.error(
742
+ `[@ossy/app][build] page "${entry.id}" layout must match @{provider}/{feature}/layout/{concept} — got "${layoutId}"`,
743
+ )
744
+ }
745
+ entry.layout = layoutId
746
+ const layoutDef = layouts.find((layout) => layout.id === layoutId)
747
+ const mergedSlots = resolvePageShellSlots({
748
+ layoutSlots: layoutDef?.slots,
749
+ appSlots: configValue?.slots,
750
+ pageSlots: meta?.rawMeta?.slots,
751
+ })
752
+ if (Object.keys(mergedSlots).length > 0) {
753
+ entry.slots = mergedSlots
754
+ }
755
+ for (const [slotKey, spec] of Object.entries(mergedSlots)) {
756
+ if (spec?.view === null) continue
757
+ const componentId = typeof spec?.view === 'string' ? spec.view.trim() : ''
758
+ if (!componentId) continue
759
+ if (!componentIdSet.has(componentId)) {
760
+ this.error(
761
+ `[@ossy/app][build] page "${entry.id}" slot "${slotKey}" references unknown component id "${componentId}"`,
762
+ )
763
+ }
764
+ }
765
+ }
766
+
692
767
  const manifest = {
693
768
  entries,
694
769
  components,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.40.3",
3
+ "version": "3.0.2",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -16,7 +16,12 @@
16
16
  "./manifest/build-actions-schema": "./src/manifest/build-actions-schema.js",
17
17
  "./manifest/build-capabilities": "./src/manifest/build-capabilities.js",
18
18
  "./manifest/discover-package-definitions": "./src/manifest/discover-package-definitions.js",
19
- "./manifest/serialize-package-definition": "./src/manifest/serialize-package-definition.js"
19
+ "./manifest/serialize-package-definition": "./src/manifest/serialize-package-definition.js",
20
+ "./manifest/resolve-page-layout": "./src/manifest/resolve-page-layout.js",
21
+ "./runtime/merge-shell-slots": "./runtime/merge-shell-slots.js"
22
+ },
23
+ "ossy": {
24
+ "src": "./src/shell-registry"
20
25
  },
21
26
  "bin": {
22
27
  "app": "./cli/index.js"
@@ -44,17 +49,21 @@
44
49
  "@babel/eslint-parser": "^7.15.8",
45
50
  "@babel/preset-react": "^7.26.3",
46
51
  "@babel/register": "^7.25.9",
47
- "@ossy/design-system": "^1.40.3",
48
- "@ossy/locale": "^1.40.3",
49
- "@ossy/pages": "^1.23.0",
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",
52
+ "@ossy/authentication": "^3.0.2",
53
+ "@ossy/design-system": "^3.0.2",
54
+ "@ossy/locale": "^3.0.2",
55
+ "@ossy/manifest": "^3.0.2",
56
+ "@ossy/package-catalog": "^3.0.2",
57
+ "@ossy/pages": "^3.0.2",
58
+ "@ossy/platform": "^3.0.2",
59
+ "@ossy/resources": "^3.0.2",
60
+ "@ossy/router": "^3.0.2",
61
+ "@ossy/router-react": "^3.0.2",
62
+ "@ossy/schema": "^3.0.2",
63
+ "@ossy/sdk": "^3.0.2",
64
+ "@ossy/sdk-react": "^3.0.2",
65
+ "@ossy/themes": "^3.0.2",
66
+ "@ossy/workspaces": "^3.0.2",
58
67
  "@rollup/plugin-alias": "^6.0.0",
59
68
  "@rollup/plugin-babel": "^7.0.0",
60
69
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -88,5 +97,5 @@
88
97
  "README.md",
89
98
  "tsconfig.json"
90
99
  ],
91
- "gitHead": "a0d89185a17f8de8ce328c3a648c108ff1d61d8f"
100
+ "gitHead": "f0a8af3c370bbaffe4f7388c14f9345a5c6c56e5"
92
101
  }
@@ -0,0 +1,148 @@
1
+ import { CONTENT_SLOT_NAME, normalizeAppSlotName } from './resolve-app-slots.js'
2
+
3
+ /**
4
+ * @typedef {{ view?: string | null, props: Record<string, unknown> }} ShellSlotSpec
5
+ */
6
+
7
+ /**
8
+ * Coerce a manifest/runtime slot value to {@link ShellSlotSpec}.
9
+ *
10
+ * @param {string | null | ShellSlotSpec | undefined} value
11
+ * @returns {ShellSlotSpec | undefined}
12
+ */
13
+ export function coerceShellSlotSpec (value) {
14
+ if (value === null) return { view: null, props: {} }
15
+ if (typeof value === 'string') {
16
+ const trimmed = value.trim()
17
+ return trimmed ? { view: trimmed, props: {} } : undefined
18
+ }
19
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
20
+ const { view, props: nestedProps, ...rest } = value
21
+ const propBag = {
22
+ ...(nestedProps && typeof nestedProps === 'object' && !Array.isArray(nestedProps) ? nestedProps : {}),
23
+ ...rest,
24
+ }
25
+ /** @type {ShellSlotSpec} */
26
+ const spec = { props: propBag }
27
+ if (view === null) spec.view = null
28
+ else if (typeof view === 'string') {
29
+ const trimmed = view.trim()
30
+ if (trimmed) spec.view = trimmed
31
+ }
32
+ return spec
33
+ }
34
+ return undefined
35
+ }
36
+
37
+ /**
38
+ * Apply one layer onto the accumulated slot spec (layout → app → page).
39
+ *
40
+ * - `null` / `{ view: null }` → unset
41
+ * - string → set view, keep inherited props
42
+ * - `{ …props }` → shallow-merge props, keep inherited view
43
+ * - `{ view, …props }` → set view + merge props
44
+ * - `{}` → no-op
45
+ *
46
+ * @param {ShellSlotSpec | undefined} current
47
+ * @param {string | null | Record<string, unknown> | undefined} value
48
+ * @returns {ShellSlotSpec | undefined}
49
+ */
50
+ export function applyShellSlotLayer (current, value) {
51
+ if (value === null) {
52
+ return { view: null, props: {} }
53
+ }
54
+
55
+ if (typeof value === 'string') {
56
+ const trimmed = value.trim()
57
+ if (!trimmed) return current
58
+ return {
59
+ view: trimmed,
60
+ props: current?.props ? { ...current.props } : {},
61
+ }
62
+ }
63
+
64
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
65
+ if (Object.keys(value).length === 0) {
66
+ return current
67
+ }
68
+
69
+ const { view, props: nestedProps, ...rest } = value
70
+ if (view === null) {
71
+ return { view: null, props: {} }
72
+ }
73
+
74
+ const restProps = {
75
+ ...(nestedProps && typeof nestedProps === 'object' && !Array.isArray(nestedProps) ? nestedProps : {}),
76
+ ...rest,
77
+ }
78
+ const nextProps = Object.keys(restProps).length > 0
79
+ ? { ...(current?.props ?? {}), ...restProps }
80
+ : { ...(current?.props ?? {}) }
81
+
82
+ /** @type {ShellSlotSpec} */
83
+ const next = { props: nextProps }
84
+ if (view !== undefined) {
85
+ if (typeof view === 'string') {
86
+ const trimmed = view.trim()
87
+ if (trimmed) next.view = trimmed
88
+ }
89
+ } else if (current?.view !== undefined) {
90
+ next.view = current.view
91
+ }
92
+
93
+ return next
94
+ }
95
+
96
+ return current
97
+ }
98
+
99
+ /**
100
+ * @param {ShellSlotSpec | string | null | undefined} spec
101
+ * @returns {boolean}
102
+ */
103
+ export function shellSlotUnset (spec) {
104
+ const normalized = typeof spec === 'string' || spec === null ? coerceShellSlotSpec(spec) : spec
105
+ return normalized?.view === null
106
+ }
107
+
108
+ /**
109
+ * @param {ShellSlotSpec | string | null | undefined} spec
110
+ * @returns {string | undefined}
111
+ */
112
+ export function shellSlotViewId (spec) {
113
+ const normalized = typeof spec === 'string' || spec === null ? coerceShellSlotSpec(spec) : spec
114
+ if (!normalized || normalized.view === null) return undefined
115
+ return typeof normalized.view === 'string' ? normalized.view : undefined
116
+ }
117
+
118
+ /**
119
+ * Merge shell slot maps per ADR 0012: layout defaults → app config → page metadata.
120
+ * Page layer wins for both view (when set) and props.
121
+ *
122
+ * @param {Record<string, string | null | Record<string, unknown>> | null | undefined} layoutSlots
123
+ * @param {Record<string, string | null | Record<string, unknown>> | null | undefined} appSlots
124
+ * @param {Record<string, string | null | Record<string, unknown>> | null | undefined} pageSlots
125
+ * @returns {Record<string, ShellSlotSpec>}
126
+ */
127
+ export function mergeShellSlots (layoutSlots, appSlots, pageSlots) {
128
+ /** @type {Record<string, ShellSlotSpec>} */
129
+ const merged = {}
130
+
131
+ const apply = (map) => {
132
+ if (!map || typeof map !== 'object' || Array.isArray(map)) return
133
+ Object.entries(map).forEach(([rawKey, value]) => {
134
+ const key = normalizeAppSlotName(rawKey)
135
+ if (!key || key === CONTENT_SLOT_NAME) return
136
+ const next = applyShellSlotLayer(merged[key], value)
137
+ if (next !== undefined) {
138
+ merged[key] = next
139
+ }
140
+ })
141
+ }
142
+
143
+ apply(layoutSlots)
144
+ apply(appSlots)
145
+ apply(pageSlots)
146
+
147
+ return merged
148
+ }