@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.
@@ -1,10 +1,15 @@
1
+ import { createElement } from 'react'
2
+ import { coerceShellSlotSpec, shellSlotViewId } from './merge-shell-slots.js'
3
+
1
4
  /** Canonical app chrome slot names (namespaced). App layout maps chrome only — not content. */
2
5
  export const APP_SLOT_NAMES = [
6
+ 'app:head',
3
7
  'app:header',
4
8
  'app:sidebar',
5
9
  'app:toolbar',
6
10
  'app:notifications',
7
11
  'app:system-messages',
12
+ 'app:footer',
8
13
  ]
9
14
 
10
15
  /** Platform-owned slot filled with the current route page component. */
@@ -14,14 +19,29 @@ const APP_SLOT_PREFIX = 'app'
14
19
 
15
20
  /** Bare app region → namespaced key (legacy fallback for app components only). */
16
21
  const BARE_APP_REGION = {
22
+ head: 'app:head',
17
23
  header: 'app:header',
18
24
  sidebar: 'app:sidebar',
19
25
  toolbar: 'app:toolbar',
20
26
  notifications: 'app:notifications',
21
27
  'system-messages': 'app:system-messages',
28
+ footer: 'app:footer',
22
29
  content: CONTENT_SLOT_NAME,
23
30
  }
24
31
 
32
+ /**
33
+ * @param {import('react').ComponentType<any>} Component
34
+ * @param {Record<string, unknown>} defaultProps
35
+ * @returns {import('react').ComponentType<any>}
36
+ */
37
+ function withShellSlotProps (Component, defaultProps) {
38
+ function ShellSlotTarget (props) {
39
+ return createElement(Component, { ...defaultProps, ...props })
40
+ }
41
+ ShellSlotTarget.displayName = `ShellSlot(${Component.displayName || Component.name || 'Component'})`
42
+ return ShellSlotTarget
43
+ }
44
+
25
45
  /**
26
46
  * Normalize a slot map key to the canonical namespaced form when it is a bare app region.
27
47
  *
@@ -34,10 +54,10 @@ export function normalizeAppSlotName (slotName) {
34
54
  }
35
55
 
36
56
  /**
37
- * Build `Record<slotName, Component>` from the app layout's static `slots` map
38
- * (`slotName → componentId`) and components loaded by `metadata.id`.
57
+ * Build `Record<slotName, Component>` from merged shell slot specs
58
+ * (`slotName → component id + default props`) and components loaded by `metadata.id`.
39
59
  *
40
- * @param {Record<string, string> | null | undefined} layoutSlotsMap
60
+ * @param {Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec | undefined> | null | undefined} layoutSlotsMap
41
61
  * @param {Record<string, import('react').ComponentType>} componentsById
42
62
  * @returns {Record<string, import('react').ComponentType>}
43
63
  */
@@ -46,16 +66,33 @@ export function resolveAppSlots (layoutSlotsMap, componentsById) {
46
66
  const resolved = {}
47
67
 
48
68
  const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
49
- for (const [rawSlot, componentId] of Object.entries(map)) {
69
+ for (const [rawSlot, rawSpec] of Object.entries(map)) {
50
70
  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
71
+ if (!slotName || slotName === CONTENT_SLOT_NAME) continue
72
+
73
+ const spec = coerceShellSlotSpec(rawSpec) ?? (
74
+ rawSpec && typeof rawSpec === 'object' && !Array.isArray(rawSpec) ? rawSpec : undefined
75
+ )
76
+
77
+ if (spec?.view === null) {
78
+ resolved[slotName] = null
79
+ continue
80
+ }
81
+
82
+ const componentId = shellSlotViewId(spec ?? rawSpec)
83
+ if (!componentId) continue
84
+
85
+ const Component = componentsById[componentId]
86
+ if (!Component) continue
87
+
88
+ const defaultProps = spec?.props ?? {}
89
+ resolved[slotName] = Object.keys(defaultProps).length > 0
90
+ ? withShellSlotProps(Component, defaultProps)
91
+ : Component
55
92
  }
56
93
 
57
94
  for (const slotName of APP_SLOT_NAMES) {
58
- if (resolved[slotName]) continue
95
+ if (slotName in resolved) continue
59
96
  if (componentsById[slotName]) {
60
97
  resolved[slotName] = componentsById[slotName]
61
98
  continue
@@ -69,10 +106,10 @@ export function resolveAppSlots (layoutSlotsMap, componentsById) {
69
106
 
70
107
  /**
71
108
  * 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/…`).
109
+ * Feature components register at canonical ADR 0006 ids (`@ossy/…/view|form|…`).
73
110
  *
74
111
  * @param {{
75
- * layoutSlots?: Record<string, string> | null,
112
+ * layoutSlots?: Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec> | null,
76
113
  * componentsById?: Record<string, import('react').ComponentType>,
77
114
  * pageComponent?: import('react').ComponentType | null,
78
115
  * }} options
@@ -0,0 +1,8 @@
1
+ {
2
+ "app.shell.sidebar.expand": "Expand sidebar",
3
+ "app.shell.sidebar.collapse": "Collapse sidebar",
4
+ "app.shell.header.languagePicker": "Language",
5
+ "app.shell.footer.languages": "Languages",
6
+ "app.shell.header.themeSwitch": "{theme} theme. Switch theme.",
7
+ "app.shell.header.profile": "Profile"
8
+ }
@@ -0,0 +1,51 @@
1
+ import { mergeShellSlots } from '../../runtime/merge-shell-slots.js'
2
+
3
+ const LAYOUT_ID_PATTERN = /^@([^/]+)\/([^/]+)\/layout\/([^/]+)$/
4
+
5
+ /**
6
+ * @param {string} layoutId
7
+ * @returns {boolean}
8
+ */
9
+ export function isCanonicalLayoutId (layoutId) {
10
+ return typeof layoutId === 'string' && LAYOUT_ID_PATTERN.test(layoutId.trim())
11
+ }
12
+
13
+ /**
14
+ * Resolve layout id for a page at build time (ADR 0012).
15
+ *
16
+ * @param {{
17
+ * pageMetadata?: { layout?: string },
18
+ * appConfig?: { layout?: string },
19
+ * }} options
20
+ * @returns {string}
21
+ */
22
+ export function resolvePageLayoutId ({
23
+ pageMetadata = {},
24
+ appConfig = {},
25
+ } = {}) {
26
+ const pageLayout = pageMetadata?.layout
27
+ if (typeof pageLayout === 'string' && pageLayout.trim()) return pageLayout.trim()
28
+
29
+ const appLayout = appConfig?.layout
30
+ if (typeof appLayout === 'string' && appLayout.trim()) return appLayout.trim()
31
+
32
+ return '@ossy/app/layout/default'
33
+ }
34
+
35
+ /**
36
+ * Merge layout, app, and page slot maps for a single page render.
37
+ *
38
+ * @param {{
39
+ * layoutSlots?: Record<string, string>,
40
+ * appSlots?: Record<string, string | null>,
41
+ * pageSlots?: Record<string, string | null>,
42
+ * }} options
43
+ * @returns {Record<string, import('../../runtime/merge-shell-slots.js').ShellSlotSpec>}
44
+ */
45
+ export function resolvePageShellSlots ({
46
+ layoutSlots,
47
+ appSlots,
48
+ pageSlots,
49
+ } = {}) {
50
+ return mergeShellSlots(layoutSlots, appSlots, pageSlots)
51
+ }
@@ -21,9 +21,8 @@ export function serializePackageDefinition (definition) {
21
21
  out.navOrder = definition.navOrder
22
22
  }
23
23
  if (definition.entitlementRequired === false) out.entitlementRequired = false
24
- if (typeof definition.status === 'string' && definition.status.trim()) out.status = definition.status.trim()
25
- if (Array.isArray(definition.statuses) && definition.statuses.length) {
26
- out.statuses = definition.statuses.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
24
+ if (Array.isArray(definition.status) && definition.status.length) {
25
+ out.status = definition.status.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
27
26
  }
28
27
 
29
28
  return Object.keys(out).length ? out : null
package/src/shell/App.jsx CHANGED
@@ -2,20 +2,22 @@ import React from 'react'
2
2
  import { SDK } from '@ossy/sdk'
3
3
  import { WorkspaceProvider } from '@ossy/sdk-react'
4
4
  import { Theme, ComponentSlotsProvider, LocaleProvider, DEFAULT_FORM_FIELD_SLOTS } from '@ossy/design-system'
5
+ import { DEFAULT_SCHEMA_VIEW_SLOTS } from '@ossy/resources/schemaViewSlots.js'
6
+ import { DEFAULT_SCHEMA_FORM_SLOTS } from '@ossy/resources/schemaFormSlots.js'
5
7
  import { SchemasReadBootstrap } from '@ossy/workspaces/SchemasReadBootstrap'
6
8
  import { ThemeEditor } from './ThemeEditor.jsx'
9
+ import { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
7
10
  import { defaultAppSettings } from './AppSettings.jsx'
8
11
  import { Router } from '@ossy/router-react'
9
12
  import { AppContext } from './AppContext.js'
10
13
 
11
- export const App = ({ children, language, messages, fallbackMessages, ..._appSettings }) => {
14
+ export const App = ({ children, language, messages, fallbackMessages, sdk: sdkOverride, ..._appSettings }) => {
12
15
  const appSettings = { ...defaultAppSettings(), ..._appSettings }
13
16
 
14
- // `components` holds resolved ComponentType values — not JSON-serializable,
15
- // so we keep them out of AppContext and provide them via ComponentSlotsProvider.
16
- const { components, ...contextSettings } = appSettings
17
+ // `components` and `sdk` are not JSON-serializable — keep them out of AppContext.
18
+ const { components, sdk: _sdkFromSettings, ...contextSettings } = appSettings
17
19
 
18
- const sdk = SDK.of({
20
+ const sdk = sdkOverride ?? SDK.of({
19
21
  apiUrl: appSettings.apiUrl,
20
22
  workspaceId: appSettings.workspaceId,
21
23
  actionRoutes: appSettings.actionRoutes,
@@ -30,10 +32,14 @@ export const App = ({ children, language, messages, fallbackMessages, ..._appSet
30
32
  supportedLanguages={appSettings.supportedLanguages}
31
33
  >
32
34
  <AppContext.Provider value={contextSettings}>
33
- <ComponentSlotsProvider slots={{ ...DEFAULT_FORM_FIELD_SLOTS, ...(components || {}) }}>
35
+ <ComponentSlotsProvider slots={{ ...DEFAULT_FORM_FIELD_SLOTS, ...DEFAULT_SCHEMA_VIEW_SLOTS, ...DEFAULT_SCHEMA_FORM_SLOTS, ...(components || {}) }}>
34
36
  <Theme theme={appSettings.theme} themes={appSettings.themes}>
35
- <WorkspaceProvider sdk={sdk}>
37
+ <WorkspaceProvider
38
+ sdk={sdk}
39
+ enablePushInvalidation={appSettings.enablePushInvalidation === true}
40
+ >
36
41
  <SchemasReadBootstrap schemas={appSettings.schemas} />
42
+ <WorkspaceAppSettingsSync />
37
43
  <Router {...appSettings} pages={appSettings.pages || []}>
38
44
  {children}
39
45
  {appSettings.devMode && <ThemeEditor />}
@@ -38,5 +38,11 @@ export function defaultAppSettings() {
38
38
  taskGraphEdges: undefined,
39
39
  /** Action id → HTTP route map for sdk.invoke transport routing. */
40
40
  actionRoutes: undefined,
41
+ /**
42
+ * When true, the app shell opens SSE push invalidation for every page.
43
+ * Default false — mount `PushInvalidationSubscriber` only on pages that
44
+ * need live cross-tab cache updates (see @ossy/sdk-react README).
45
+ */
46
+ enablePushInvalidation: false,
41
47
  }
42
48
  }
@@ -0,0 +1,49 @@
1
+ import React from 'react'
2
+ import { Button, useLocale } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { OpenSignIn, OpenSignUp } from '@ossy/authentication'
5
+ import { useApp } from './AppContext.js'
6
+
7
+ /**
8
+ * Profile link when authenticated; sign-in / sign-up when not.
9
+ */
10
+ export function HeaderAuthActions ({ compact = false }) {
11
+ const app = useApp()
12
+ const router = useRouter()
13
+ const { t } = useLocale()
14
+
15
+ if (app?.isAuthenticated) {
16
+ return (
17
+ <Button
18
+ variant="link"
19
+ suffix="profile"
20
+ href={router.getHref('@profile')}
21
+ aria-label={t('app.shell.header.profile') || 'Profile'}
22
+ style={{ flexShrink: 0 }}
23
+ >
24
+ {compact ? null : (t('app.shell.header.profile') || 'Profile')}
25
+ </Button>
26
+ )
27
+ }
28
+
29
+ return (
30
+ <>
31
+ <Button
32
+ variant="link"
33
+ suffix={OpenSignIn.suffix}
34
+ href={router.getHref('@sign-in')}
35
+ label={compact ? undefined : OpenSignIn.label}
36
+ aria-label={t(OpenSignIn.label)}
37
+ style={{ flexShrink: 0 }}
38
+ />
39
+ <Button
40
+ variant="cta"
41
+ suffix={OpenSignUp.suffix}
42
+ href={router.getHref('@sign-up')}
43
+ label={compact ? undefined : OpenSignUp.label}
44
+ aria-label={t(OpenSignUp.label)}
45
+ style={{ flexShrink: 0 }}
46
+ />
47
+ </>
48
+ )
49
+ }
@@ -0,0 +1,83 @@
1
+ import React, { useMemo } from 'react'
2
+ import { Text, View, useLocale } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { languageDisplayName } from './languageCode.js'
5
+
6
+ const listItemStyle = {
7
+ display: 'block',
8
+ width: '100%',
9
+ textAlign: 'left',
10
+ }
11
+
12
+ /**
13
+ * Footer language column when multiple locales are configured.
14
+ */
15
+ export function LanguageList () {
16
+ const router = useRouter()
17
+ const { t } = useLocale()
18
+ const { language, supportedLanguages, getHref } = router
19
+
20
+ const displayNames = useMemo(
21
+ () => new Intl.DisplayNames([language], { type: 'language' }),
22
+ [language],
23
+ )
24
+
25
+ if (!supportedLanguages?.length || supportedLanguages.length <= 1) {
26
+ return null
27
+ }
28
+
29
+ const listLabel = t('app.shell.footer.languages') || 'Languages'
30
+
31
+ return (
32
+ <View layout="column" gap="s" alignItems="flex-start" style={{ minWidth: 0 }}>
33
+ <Text variant="heading-tertiary" as="h2" style={listItemStyle}>
34
+ {listLabel}
35
+ </Text>
36
+ <View
37
+ as="nav"
38
+ aria-label={listLabel}
39
+ style={{ width: '100%' }}
40
+ >
41
+ <View
42
+ as="ul"
43
+ layout="column"
44
+ gap="xs"
45
+ alignItems="flex-start"
46
+ style={{ listStyle: 'none', margin: 0, padding: 0, width: '100%' }}
47
+ >
48
+ {supportedLanguages.map((lang) => {
49
+ const isActive = lang === language
50
+ const label = displayNames.of(lang) ?? languageDisplayName(lang, language)
51
+
52
+ if (isActive) {
53
+ return (
54
+ <View as="li" key={lang} style={{ width: '100%' }}>
55
+ <Text variant="small" as="span" aria-current="true" style={listItemStyle}>
56
+ {label}
57
+ </Text>
58
+ </View>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <View as="li" key={lang} style={{ width: '100%' }}>
64
+ <Text
65
+ variant="small"
66
+ as="a"
67
+ href={getHref({ language: lang })}
68
+ style={{
69
+ ...listItemStyle,
70
+ textDecoration: 'underline',
71
+ cursor: 'pointer',
72
+ }}
73
+ >
74
+ {label}
75
+ </Text>
76
+ </View>
77
+ )
78
+ })}
79
+ </View>
80
+ </View>
81
+ </View>
82
+ )
83
+ }
@@ -0,0 +1,73 @@
1
+ import React from 'react'
2
+ import {
3
+ Button,
4
+ ContextMenu,
5
+ Dropdown,
6
+ View,
7
+ useLocale,
8
+ } from '@ossy/design-system'
9
+ import { useRouter } from '@ossy/router-react'
10
+ import { languageCode } from './languageCode.js'
11
+
12
+ /**
13
+ * Language switcher for app shell header — cycle when two locales, dropdown when more.
14
+ */
15
+ export function LanguageSelect () {
16
+ const router = useRouter()
17
+ const { t } = useLocale()
18
+ const { language, supportedLanguages, getHref } = router
19
+
20
+ if (!supportedLanguages?.length || supportedLanguages.length <= 1) {
21
+ return null
22
+ }
23
+
24
+ const pickerLabel = t('app.shell.header.languagePicker') || 'Language'
25
+
26
+ if (supportedLanguages.length === 2) {
27
+ const other = supportedLanguages.find((lang) => lang !== language) ?? supportedLanguages[0]
28
+
29
+ return (
30
+ <Button
31
+ variant="link"
32
+ prefix="select"
33
+ href={getHref({ language: other })}
34
+ aria-label={pickerLabel}
35
+ style={{ flexShrink: 0 }}
36
+ >
37
+ {languageCode(language)}
38
+ </Button>
39
+ )
40
+ }
41
+
42
+ return (
43
+ <Dropdown
44
+ trigger={(
45
+ <Button
46
+ prefix="select"
47
+ variant="link"
48
+ aria-label={pickerLabel}
49
+ style={{ flexShrink: 0 }}
50
+ >
51
+ {languageCode(language)}
52
+ </Button>
53
+ )}
54
+ >
55
+ <View inset="xs" surface="primary" roundness="s">
56
+ <ContextMenu roundness="s" surface="primary">
57
+ {supportedLanguages.map((lang) => {
58
+ const isActive = lang === language
59
+ return (
60
+ <ContextMenu.Item
61
+ key={lang}
62
+ href={isActive ? undefined : getHref({ language: lang })}
63
+ aria-current={isActive ? 'true' : undefined}
64
+ >
65
+ {languageCode(lang)}
66
+ </ContextMenu.Item>
67
+ )
68
+ })}
69
+ </ContextMenu>
70
+ </View>
71
+ </Dropdown>
72
+ )
73
+ }
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useCallback, useEffect } from 'react'
2
- import { updateResourceContent } from '@ossy/resources'
2
+ import { updateResourceContent } from '@ossy/resources/resource.helpers.js'
3
3
  import { useSdk } from '@ossy/sdk-react'
4
4
  import { Overlay, Button, useTheme, View, Text } from '@ossy/design-system'
5
5
  import { DevPagesPanel } from './DevPagesPanel.jsx'
@@ -68,7 +68,7 @@ export const ThemeEditor = () => {
68
68
  isEditorOpen && (
69
69
  <Overlay isVisible={true} onClose={onToggle}>
70
70
  <View layout="off-center" data-component="theme-editor-overlay">
71
- <View slot="content" data-component="theme-editor-panel">
71
+ <View data-region="content" data-component="theme-editor-panel">
72
72
  <View surface="primary" roundness="m" gap="m" inset="l">
73
73
  <DevPagesPanel />
74
74
  <View as="form" gap="s" onSubmit={onSaveTheme} onChange={onThemeChange}>
@@ -0,0 +1,101 @@
1
+ import React, { useCallback } from 'react'
2
+ import {
3
+ Button,
4
+ ContextMenu,
5
+ Dropdown,
6
+ View,
7
+ useLocale,
8
+ useTheme,
9
+ } from '@ossy/design-system'
10
+ import { patchUserAppSettings } from './patchUserAppSettings.js'
11
+
12
+ const THEME_ICON_BY_NAME = {
13
+ light: 'sun',
14
+ dark: 'moon',
15
+ 'cloud-light': 'sun',
16
+ 'cloud-dark': 'moon',
17
+ }
18
+
19
+ function themeLabel (name) {
20
+ if (typeof name !== 'string' || !name) {
21
+ return 'Theme'
22
+ }
23
+ return name
24
+ .split('-')
25
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
26
+ .join(' ')
27
+ }
28
+
29
+ function themeIcon (name) {
30
+ return THEME_ICON_BY_NAME[name] || 'dark-mode'
31
+ }
32
+
33
+ /**
34
+ * Theme switcher for app shell header — cycle when two themes, dropdown when more.
35
+ */
36
+ export function ThemeSelect ({ compact = false }) {
37
+ const { themes, activeTheme, setTheme } = useTheme()
38
+ const { t } = useLocale()
39
+
40
+ const saveTheme = useCallback((themeName) => {
41
+ setTheme(themeName)
42
+ patchUserAppSettings({ theme: themeName }).catch(() => {})
43
+ }, [setTheme])
44
+
45
+ if (!themes?.length || themes.length <= 1) {
46
+ return null
47
+ }
48
+
49
+ const activeLabel = themeLabel(activeTheme)
50
+ const ariaLabel =
51
+ t('app.shell.header.themeSwitch', { theme: activeLabel })
52
+ || `${activeLabel} theme. Switch theme.`
53
+
54
+ if (themes.length === 2) {
55
+ const cycleTheme = () => {
56
+ const index = Math.max(0, themes.indexOf(activeTheme))
57
+ saveTheme(themes[(index + 1) % themes.length])
58
+ }
59
+
60
+ return (
61
+ <Button
62
+ variant="link"
63
+ prefix={themeIcon(activeTheme)}
64
+ onClick={cycleTheme}
65
+ aria-label={ariaLabel}
66
+ style={{ flexShrink: 0 }}
67
+ >
68
+ {compact ? null : activeLabel}
69
+ </Button>
70
+ )
71
+ }
72
+
73
+ return (
74
+ <Dropdown
75
+ trigger={(
76
+ <Button
77
+ prefix={compact ? themeIcon(activeTheme) : 'select'}
78
+ variant="link"
79
+ aria-label={ariaLabel}
80
+ style={{ flexShrink: 0 }}
81
+ >
82
+ {compact ? null : activeLabel}
83
+ </Button>
84
+ )}
85
+ >
86
+ <View inset="xs" surface="primary" roundness="s">
87
+ <ContextMenu roundness="s" surface="primary">
88
+ {themes.map((name) => (
89
+ <ContextMenu.Item
90
+ key={name}
91
+ onClick={() => saveTheme(name)}
92
+ aria-current={name === activeTheme ? 'true' : undefined}
93
+ >
94
+ {themeLabel(name)}
95
+ </ContextMenu.Item>
96
+ ))}
97
+ </ContextMenu>
98
+ </View>
99
+ </Dropdown>
100
+ )
101
+ }
@@ -0,0 +1,113 @@
1
+ import { slugToDisplayName } from '@ossy/package-catalog'
2
+ import { isServiceEntitled } from '@ossy/workspaces/entitlements'
3
+ import { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
4
+
5
+ /**
6
+ * @typedef {object} SidebarShellItem
7
+ * @property {string} id
8
+ * @property {string} label
9
+ * @property {string} prefix
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} SidebarPackageItem
14
+ * @property {string} slug
15
+ * @property {string} label
16
+ * @property {string} icon
17
+ * @property {string} pageId
18
+ * @property {number} order
19
+ */
20
+
21
+ /**
22
+ * Pick the package "home" page from manifest pages.
23
+ * Convention: slug plus "/home", then any page id ending in "/home", then shortest route path.
24
+ *
25
+ * @param {Array<{ id?: string, path?: string | Record<string, string> }>} pages
26
+ * @param {string} slug
27
+ * @returns {string | null}
28
+ */
29
+ export function resolvePackageHomePageId (pages, slug) {
30
+ const list = (pages || []).filter((page) => page?.id)
31
+ if (!list.length) return null
32
+
33
+ const conventional = list.find((page) => page.id === `${slug}/home`)
34
+ if (conventional) return conventional.id
35
+
36
+ const anyHome = list.find((page) => page.id === 'home' || page.id.endsWith('/home'))
37
+ if (anyHome) return anyHome.id
38
+
39
+ const pathLength = (page) => {
40
+ const path = page.path
41
+ if (typeof path === 'string') return path.length
42
+ if (path && typeof path === 'object') {
43
+ return Math.min(...Object.values(path).map((p) => (typeof p === 'string' ? p.length : 999)))
44
+ }
45
+ return 999
46
+ }
47
+
48
+ const byPath = [...list].sort((a, b) => pathLength(a) - pathLength(b) || a.id.localeCompare(b.id))
49
+ return byPath[0].id
50
+ }
51
+
52
+ /**
53
+ * Build entitlement-filtered sidebar navigation — one link per entitled package home.
54
+ *
55
+ * @param {{
56
+ * manifestSummary?: { packages?: Array<{ slug?: string, package?: string, pages?: Array<{ id: string, title?: string, path?: string | Record<string, string> }> }> }
57
+ * workspaceServices?: Record<string, { enabled?: boolean }>
58
+ * definitions?: Record<string, object>
59
+ * isAuthenticated?: boolean
60
+ * workspaceId?: string
61
+ * devMode?: boolean
62
+ * devEntitlements?: Record<string, { enabled?: boolean }>
63
+ * shellItems?: SidebarShellItem[]
64
+ * excludeSlugs?: string[] | Set<string>
65
+ * }} options
66
+ * @returns {{ shellItems: SidebarShellItem[], packageItems: SidebarPackageItem[], services: Record<string, { enabled?: boolean }> }}
67
+ */
68
+ export function buildSidebarNav ({
69
+ manifestSummary,
70
+ workspaceServices,
71
+ definitions = {},
72
+ isAuthenticated = false,
73
+ workspaceId,
74
+ devMode = false,
75
+ devEntitlements,
76
+ shellItems = [],
77
+ excludeSlugs = [],
78
+ }) {
79
+ const services = resolveWorkspaceServices({ devMode, devEntitlements, workspaceServices })
80
+ const excluded = excludeSlugs instanceof Set ? excludeSlugs : new Set(excludeSlugs)
81
+
82
+ const canShowProductGroups =
83
+ (isAuthenticated && workspaceId) ||
84
+ (devMode && devEntitlements && Object.keys(devEntitlements).length > 0)
85
+
86
+ const packageItems = !canShowProductGroups
87
+ ? []
88
+ : (manifestSummary?.packages || [])
89
+ .filter((pkg) => pkg.slug && !excluded.has(pkg.slug))
90
+ .filter((pkg) => !definitions[pkg.slug]?.status?.includes('hidden'))
91
+ .filter((pkg) => {
92
+ const def = definitions[pkg.slug]
93
+ const entitlementRequired = def?.entitlementRequired !== false
94
+ return !entitlementRequired || isServiceEntitled(services, pkg.package)
95
+ })
96
+ .map((pkg) => {
97
+ const slug = pkg.slug
98
+ const def = definitions[slug]
99
+ const pageId = resolvePackageHomePageId(pkg.pages, slug)
100
+ if (!pageId) return null
101
+ return {
102
+ slug,
103
+ label: def?.title || slugToDisplayName(slug),
104
+ icon: def?.icon || 'package',
105
+ order: def?.navOrder ?? 100,
106
+ pageId,
107
+ }
108
+ })
109
+ .filter(Boolean)
110
+ .sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
111
+
112
+ return { shellItems, packageItems, services }
113
+ }