@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 +6 -0
- package/cli/get-platform-files.task.js +4 -6
- package/cli/manifest-plugin.js +100 -33
- package/package.json +22 -14
- package/runtime/merge-shell-slots.js +148 -0
- package/runtime/page-runtime.js +32 -16
- package/runtime/resolve-app-slots.js +48 -11
- package/src/en.translations.json +8 -0
- package/src/manifest/resolve-page-layout.js +51 -0
- package/src/manifest/serialize-package-definition.js +2 -3
- package/src/shell/App.jsx +13 -7
- package/src/shell/AppSettings.jsx +6 -0
- package/src/shell/HeaderAuthActions.jsx +49 -0
- package/src/shell/LanguageList.jsx +83 -0
- package/src/shell/LanguageSelect.jsx +73 -0
- package/src/shell/ThemeEditor.jsx +2 -2
- package/src/shell/ThemeSelect.jsx +101 -0
- package/src/shell/buildSidebarNav.js +113 -0
- package/src/shell/index.js +6 -0
- package/src/shell/languageCode.js +31 -0
- package/src/shell/useCompactShellLayout.js +24 -0
- package/src/shell-registry/blank.layout.jsx +8 -0
- package/src/shell-registry/default.layout.jsx +101 -0
- package/src/shell-registry/footer-default.component.jsx +39 -0
- package/src/shell-registry/head-default.component.jsx +17 -0
- package/src/shell-registry/header-default.component.jsx +50 -0
- package/src/shell-registry/logo-logomark.component.jsx +8 -0
- package/src/shell-registry/logo-logotype.component.jsx +15 -0
- package/src/shell-registry/minimal.layout.jsx +58 -0
- package/src/shell-registry/sidebar-default.component.jsx +281 -0
- package/src/sv.translations.json +8 -0
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
|
|
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
|
-
|
|
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
|
|
241
|
-
// app
|
|
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),
|
package/cli/manifest-plugin.js
CHANGED
|
@@ -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
|
|
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
|
|
453
|
-
// export (the React component).
|
|
454
|
-
//
|
|
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
|
|
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 (
|
|
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
|
|
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": "
|
|
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/
|
|
48
|
-
"@ossy/
|
|
49
|
-
"@ossy/
|
|
50
|
-
"@ossy/
|
|
51
|
-
"@ossy/
|
|
52
|
-
"@ossy/
|
|
53
|
-
"@ossy/
|
|
54
|
-
"@ossy/
|
|
55
|
-
"@ossy/
|
|
56
|
-
"@ossy/
|
|
57
|
-
"@ossy/
|
|
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": "
|
|
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
|
+
}
|
package/runtime/page-runtime.js
CHANGED
|
@@ -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
|
|
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.
|
|
35
|
-
*
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
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',
|
|
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 {
|
|
131
|
-
|
|
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({
|
|
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({
|
|
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)
|