@ossy/app 1.40.3 → 3.0.1

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/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),
@@ -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) {
483
+ this.error(
484
+ `[@ossy/app][build] layout entry ${entryInfo.sourcePath} must export metadata.id (canonical layout id)`,
485
+ )
486
+ }
487
+ if (!isCanonicalLayoutId(layoutId)) {
460
488
  this.error(
461
- `[@ossy/app][build] layout entry ${entryInfo.sourcePath} must export a non-empty string "id"`,
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
 
@@ -570,7 +598,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
570
598
  'Assign components to app slots in `*.layout.jsx` via `export const slots`.',
571
599
  )
572
600
  }
573
- components.push({ id, entry: url, package: entryPackage(entryInfo, appPackageName) })
601
+ components.push(withSourcePath({ id, entry: url, package: entryPackage(entryInfo, appPackageName) }, entryInfo, projectRoot))
574
602
  continue
575
603
  }
576
604
 
@@ -589,13 +617,17 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
589
617
  ),
590
618
  entryInfo.sourcePath,
591
619
  )
592
- entries.push({
620
+ entries.push(withSourcePath({
593
621
  type: 'page',
594
622
  id,
595
623
  path: pagePath,
596
624
  title: rawMeta.title,
597
625
  entry: url,
598
626
  package: entryPackage(entryInfo, appPackageName),
627
+ }, entryInfo, projectRoot))
628
+ pageMetaById.set(id, {
629
+ rawMeta,
630
+ packageName: entryPackage(entryInfo, appPackageName),
599
631
  })
600
632
  } else if (entryInfo.kind === 'api') {
601
633
  // APIs have no sensible default route (unlike pages where we can
@@ -626,7 +658,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
626
658
  ...(Array.isArray(rawMeta.query) && rawMeta.query.length ? { query: rawMeta.query } : {}),
627
659
  }
628
660
  }
629
- entries.push(apiEntry)
661
+ entries.push(withSourcePath(apiEntry, entryInfo, projectRoot))
630
662
  }
631
663
  }
632
664
 
@@ -677,18 +709,53 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
677
709
 
678
710
  const taskGraphEdges = buildTaskCatalogEdges(taskCatalog)
679
711
 
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
712
  /** @type {Manifest} */
688
713
  const definitions = projectRoot
689
714
  ? await discoverPackageDefinitions(projectRoot)
690
715
  : {}
691
716
 
717
+ const layoutIdSet = new Set(layouts.map((layout) => layout.id))
718
+ const componentIdSet = new Set(components.map((component) => component.id))
719
+
720
+ for (const entry of entries) {
721
+ if (entry.type !== 'page') continue
722
+ const meta = pageMetaById.get(entry.id)
723
+ const layoutId = resolvePageLayoutId({
724
+ pageMetadata: meta?.rawMeta ?? {},
725
+ appConfig: configValue ?? {},
726
+ })
727
+ if (!layoutIdSet.has(layoutId)) {
728
+ this.error(
729
+ `[@ossy/app][build] page "${entry.id}" references layout "${layoutId}" which is not in manifest.layouts`,
730
+ )
731
+ }
732
+ if (!isCanonicalLayoutId(layoutId)) {
733
+ this.error(
734
+ `[@ossy/app][build] page "${entry.id}" layout must match @{provider}/{feature}/layout/{concept} — got "${layoutId}"`,
735
+ )
736
+ }
737
+ entry.layout = layoutId
738
+ const layoutDef = layouts.find((layout) => layout.id === layoutId)
739
+ const mergedSlots = resolvePageShellSlots({
740
+ layoutSlots: layoutDef?.slots,
741
+ appSlots: configValue?.slots,
742
+ pageSlots: meta?.rawMeta?.slots,
743
+ })
744
+ if (Object.keys(mergedSlots).length > 0) {
745
+ entry.slots = mergedSlots
746
+ }
747
+ for (const [slotKey, spec] of Object.entries(mergedSlots)) {
748
+ if (spec?.view === null) continue
749
+ const componentId = typeof spec?.view === 'string' ? spec.view.trim() : ''
750
+ if (!componentId) continue
751
+ if (!componentIdSet.has(componentId)) {
752
+ this.error(
753
+ `[@ossy/app][build] page "${entry.id}" slot "${slotKey}" references unknown component id "${componentId}"`,
754
+ )
755
+ }
756
+ }
757
+ }
758
+
692
759
  const manifest = {
693
760
  entries,
694
761
  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.1",
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,20 @@
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.1",
53
+ "@ossy/design-system": "^3.0.1",
54
+ "@ossy/locale": "^3.0.1",
55
+ "@ossy/package-catalog": "^3.0.1",
56
+ "@ossy/pages": "^3.0.1",
57
+ "@ossy/platform": "^3.0.1",
58
+ "@ossy/resources": "^3.0.1",
59
+ "@ossy/router": "^3.0.1",
60
+ "@ossy/router-react": "^3.0.1",
61
+ "@ossy/schema": "^3.0.1",
62
+ "@ossy/sdk": "^3.0.1",
63
+ "@ossy/sdk-react": "^3.0.1",
64
+ "@ossy/themes": "^3.0.1",
65
+ "@ossy/workspaces": "^3.0.1",
58
66
  "@rollup/plugin-alias": "^6.0.0",
59
67
  "@rollup/plugin-babel": "^7.0.0",
60
68
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -88,5 +96,5 @@
88
96
  "README.md",
89
97
  "tsconfig.json"
90
98
  ],
91
- "gitHead": "a0d89185a17f8de8ce328c3a648c108ff1d61d8f"
99
+ "gitHead": "4a70ec216448680d2fddc57b2a8a0077489720ae"
92
100
  }
@@ -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
+ }
@@ -1,7 +1,8 @@
1
1
  import { createElement } from 'react'
2
2
  import { pageIdToDocumentTitleKey, resolveMessage } from '@ossy/locale'
3
+ import { Slot } from '@ossy/design-system'
3
4
  import { App } from '../src/shell/App.jsx'
4
- import { resolvePageSlots } from './resolve-app-slots.js'
5
+ import { resolvePageSlots, CONTENT_SLOT_NAME } from './resolve-app-slots.js'
5
6
 
6
7
  /**
7
8
  * Dynamically imports each component bundle listed in `entries` and returns a
@@ -11,14 +12,17 @@ import { resolvePageSlots } from './resolve-app-slots.js'
11
12
  * skipped so a broken component never prevents the page from rendering.
12
13
  *
13
14
  * @param {Array<{ id: string, entry: string }>} entries
15
+ * @param {(entry: string) => string} [resolveEntry] — map manifest `/static/…` paths to
16
+ * absolute `file://` URLs on the server; omit in the browser (paths resolve via HTTP).
14
17
  * @returns {Promise<Record<string, import('react').ComponentType>>}
15
18
  */
16
- export async function loadComponents (entries = []) {
19
+ export async function loadComponents (entries = [], resolveEntry) {
17
20
  const map = {}
21
+ const resolve = typeof resolveEntry === 'function' ? resolveEntry : (entry) => entry
18
22
  await Promise.all(
19
23
  entries.map(async ({ id, entry }) => {
20
24
  try {
21
- const mod = await import(entry)
25
+ const mod = await import(resolve(entry))
22
26
  if (mod && mod.default) map[id] = mod.default
23
27
  } catch {
24
28
  // silently skip broken component bundles
@@ -29,10 +33,10 @@ export async function loadComponents (entries = []) {
29
33
  }
30
34
 
31
35
  /**
32
- * Dynamically imports the app's single layout bundle. Layout components cannot
36
+ * Dynamically imports the layout bundle for the current page. Layout components cannot
33
37
  * be serialized into the bootstrap JSON (functions are stripped), so the server
34
- * passes `layoutEntry` and the client loads the same module here. When present,
35
- * every page is wrapped route-specific chrome is decided inside the layout.
38
+ * passes `layoutEntry` and the client loads the same module here. Each page may
39
+ * reference a different layout id (resolved at build time per ADR 0012).
36
40
  *
37
41
  * @param {string | null | undefined} layoutEntry
38
42
  * @returns {Promise<import('react').ComponentType | null>}
@@ -76,20 +80,22 @@ function resolveDocumentTitle (metadata, props) {
76
80
  return metadata.title || props.documentTitle || ''
77
81
  }
78
82
 
79
- function buildTree ({ Component, Layout, metadata, props }) {
83
+ /** Page body is registered on `app:content`; layouts only gate shell chrome around it. */
84
+ function buildTree ({ Layout, metadata, props }) {
80
85
  const lang = props.language || props.defaultLanguage || 'en'
81
- const pageEl = createElement(Component, props)
82
- const contentEl = Layout ? createElement(Layout, props, pageEl) : pageEl
86
+ const contentEl = Layout
87
+ ? createElement(Layout, { shellSlots: props.layoutSlots ?? {}, ...props })
88
+ : createElement(Slot, { view: CONTENT_SLOT_NAME })
83
89
  return createElement(
84
90
  'html',
85
- { lang },
91
+ { lang, style: { height: '100%' } },
86
92
  createElement(
87
93
  'head',
88
94
  null,
89
95
  createElement('meta', { charSet: 'utf-8' }),
90
96
  createElement('title', null, resolveDocumentTitle(metadata, props)),
91
97
  ),
92
- createElement('body', null, createElement(App, props, contentEl)),
98
+ createElement('body', { style: { height: '100%', margin: 0 } }, createElement(App, props, contentEl)),
93
99
  )
94
100
  }
95
101
 
@@ -127,20 +133,26 @@ export function createPageEntry (pageModule, options = {}) {
127
133
  import('node:stream'),
128
134
  ])
129
135
 
130
- const { Layout = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
131
- const componentsById = await loadComponents(componentEntries)
136
+ const {
137
+ Layout = null,
138
+ componentEntries = [],
139
+ layoutSlots = {},
140
+ resolveComponentEntry,
141
+ ...pageProps
142
+ } = props
143
+ const componentsById = await loadComponents(componentEntries, resolveComponentEntry)
132
144
  const PageContent = (slotProps = {}) => createElement(Component, { ...pageProps, ...slotProps })
133
145
  const components = resolvePageSlots({
134
146
  layoutSlots,
135
147
  componentsById,
136
148
  pageComponent: PageContent,
137
149
  })
138
- const tree = buildTree({ Component, Layout, metadata, props: { ...pageProps, components } })
150
+ const tree = buildTree({ Layout, metadata, props: { ...pageProps, components, layoutSlots } })
139
151
  const bootstrapUrl = toBootstrapUrl(entryUrl)
140
152
  const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
141
153
  // Layout is a React component — JSON.stringify drops functions, so hydration
142
154
  // would render without the shell unless we pass the serializable entry URL.
143
- const { Layout: _layout, ...bootstrapProps } = props
155
+ const { Layout: _layout, resolveComponentEntry: _resolveComponentEntry, ...bootstrapProps } = props
144
156
  const bootstrapScriptContent =
145
157
  'window.__OSSY__=' + escapeBootstrapJson(JSON.stringify(bootstrapProps ?? {}))
146
158
 
@@ -182,7 +194,11 @@ export function createPageEntry (pageModule, options = {}) {
182
194
  componentsById,
183
195
  pageComponent: PageContent,
184
196
  })
185
- hydrateRoot(document, buildTree({ Component, Layout, metadata, props: { ...pageProps, components } }))
197
+ hydrateRoot(document, buildTree({
198
+ Layout,
199
+ metadata,
200
+ props: { ...pageProps, components, layoutSlots },
201
+ }))
186
202
  }).catch((err) => {
187
203
  if (typeof console !== 'undefined' && typeof console.error === 'function') {
188
204
  console.error('[@ossy/app][page-runtime] Hydration failed:', err)