@ossy/app 1.40.2 → 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 +49 -12
- package/cli/build.task.js +49 -10
- package/cli/get-platform-files.task.js +21 -15
- package/cli/manifest-plugin.js +340 -48
- package/package.json +25 -13
- package/runtime/merge-shell-slots.js +148 -0
- package/runtime/page-runtime.js +36 -18
- package/runtime/resolve-app-slots.js +126 -0
- package/src/en.translations.json +8 -0
- package/src/manifest/action-id-to-tool-name.js +29 -0
- package/src/manifest/build-action-input-schema.js +220 -0
- package/src/manifest/build-actions-schema.js +36 -0
- package/src/manifest/build-capabilities.js +190 -0
- package/src/manifest/build-manifest-summary.js +11 -5
- package/src/manifest/extract-task-catalog.js +1 -0
- package/src/manifest/resolve-page-layout.js +51 -0
- package/src/manifest/serialize-package-definition.js +2 -3
- package/src/manifest/template-to-json-schema.js +103 -0
- package/src/shell/App.jsx +18 -9
- package/src/shell/AppSettings.jsx +17 -0
- package/src/shell/DevPagesPanel.jsx +17 -13
- 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 +19 -34
- package/src/shell/ThemeSelect.jsx +101 -0
- package/src/shell/WorkspaceAppSettingsSync.jsx +39 -0
- package/src/shell/buildSidebarNav.js +113 -0
- package/src/shell/index.js +11 -0
- package/src/shell/languageCode.js +31 -0
- package/src/shell/patchUserAppSettings.js +10 -0
- package/src/shell/resolveEndpoints.js +43 -0
- package/src/shell/resolveWorkspaceServices.js +20 -0
- package/src/shell/themeEditorStyles.js +49 -0
- package/src/shell/useCompactShellLayout.js +24 -0
- package/src/shell/useShellWorkspace.js +40 -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/runtime/resolve-shell-slots.js +0 -112
|
@@ -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-
|
|
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-shell-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,19 +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)
|
|
144
|
+
const PageContent = (slotProps = {}) => createElement(Component, { ...pageProps, ...slotProps })
|
|
132
145
|
const components = resolvePageSlots({
|
|
133
146
|
layoutSlots,
|
|
134
147
|
componentsById,
|
|
135
|
-
pageComponent:
|
|
148
|
+
pageComponent: PageContent,
|
|
136
149
|
})
|
|
137
|
-
const tree = buildTree({
|
|
150
|
+
const tree = buildTree({ Layout, metadata, props: { ...pageProps, components, layoutSlots } })
|
|
138
151
|
const bootstrapUrl = toBootstrapUrl(entryUrl)
|
|
139
152
|
const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
|
|
140
153
|
// Layout is a React component — JSON.stringify drops functions, so hydration
|
|
141
154
|
// would render without the shell unless we pass the serializable entry URL.
|
|
142
|
-
const { Layout: _layout, ...bootstrapProps } = props
|
|
155
|
+
const { Layout: _layout, resolveComponentEntry: _resolveComponentEntry, ...bootstrapProps } = props
|
|
143
156
|
const bootstrapScriptContent =
|
|
144
157
|
'window.__OSSY__=' + escapeBootstrapJson(JSON.stringify(bootstrapProps ?? {}))
|
|
145
158
|
|
|
@@ -175,12 +188,17 @@ export function createPageEntry (pageModule, options = {}) {
|
|
|
175
188
|
loadComponents(componentEntries),
|
|
176
189
|
loadLayout(layoutEntry),
|
|
177
190
|
]).then(([{ hydrateRoot }, componentsById, Layout]) => {
|
|
191
|
+
const PageContent = (slotProps = {}) => createElement(Component, { ...pageProps, ...slotProps })
|
|
178
192
|
const components = resolvePageSlots({
|
|
179
193
|
layoutSlots,
|
|
180
194
|
componentsById,
|
|
181
|
-
pageComponent:
|
|
195
|
+
pageComponent: PageContent,
|
|
182
196
|
})
|
|
183
|
-
hydrateRoot(document, buildTree({
|
|
197
|
+
hydrateRoot(document, buildTree({
|
|
198
|
+
Layout,
|
|
199
|
+
metadata,
|
|
200
|
+
props: { ...pageProps, components, layoutSlots },
|
|
201
|
+
}))
|
|
184
202
|
}).catch((err) => {
|
|
185
203
|
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
186
204
|
console.error('[@ossy/app][page-runtime] Hydration failed:', err)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createElement } from 'react'
|
|
2
|
+
import { coerceShellSlotSpec, shellSlotViewId } from './merge-shell-slots.js'
|
|
3
|
+
|
|
4
|
+
/** Canonical app chrome slot names (namespaced). App layout maps chrome only — not content. */
|
|
5
|
+
export const APP_SLOT_NAMES = [
|
|
6
|
+
'app:head',
|
|
7
|
+
'app:header',
|
|
8
|
+
'app:sidebar',
|
|
9
|
+
'app:toolbar',
|
|
10
|
+
'app:notifications',
|
|
11
|
+
'app:system-messages',
|
|
12
|
+
'app:footer',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/** Platform-owned slot filled with the current route page component. */
|
|
16
|
+
export const CONTENT_SLOT_NAME = 'app:content'
|
|
17
|
+
|
|
18
|
+
const APP_SLOT_PREFIX = 'app'
|
|
19
|
+
|
|
20
|
+
/** Bare app region → namespaced key (legacy fallback for app components only). */
|
|
21
|
+
const BARE_APP_REGION = {
|
|
22
|
+
head: 'app:head',
|
|
23
|
+
header: 'app:header',
|
|
24
|
+
sidebar: 'app:sidebar',
|
|
25
|
+
toolbar: 'app:toolbar',
|
|
26
|
+
notifications: 'app:notifications',
|
|
27
|
+
'system-messages': 'app:system-messages',
|
|
28
|
+
footer: 'app:footer',
|
|
29
|
+
content: CONTENT_SLOT_NAME,
|
|
30
|
+
}
|
|
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
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize a slot map key to the canonical namespaced form when it is a bare app region.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} slotName
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeAppSlotName (slotName) {
|
|
52
|
+
const key = typeof slotName === 'string' ? slotName.trim() : ''
|
|
53
|
+
return BARE_APP_REGION[key] ?? key
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build `Record<slotName, Component>` from merged shell slot specs
|
|
58
|
+
* (`slotName → component id + default props`) and components loaded by `metadata.id`.
|
|
59
|
+
*
|
|
60
|
+
* @param {Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec | undefined> | null | undefined} layoutSlotsMap
|
|
61
|
+
* @param {Record<string, import('react').ComponentType>} componentsById
|
|
62
|
+
* @returns {Record<string, import('react').ComponentType>}
|
|
63
|
+
*/
|
|
64
|
+
export function resolveAppSlots (layoutSlotsMap, componentsById) {
|
|
65
|
+
/** @type {Record<string, import('react').ComponentType>} */
|
|
66
|
+
const resolved = {}
|
|
67
|
+
|
|
68
|
+
const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
|
|
69
|
+
for (const [rawSlot, rawSpec] of Object.entries(map)) {
|
|
70
|
+
const slotName = normalizeAppSlotName(rawSlot)
|
|
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
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const slotName of APP_SLOT_NAMES) {
|
|
95
|
+
if (slotName in resolved) continue
|
|
96
|
+
if (componentsById[slotName]) {
|
|
97
|
+
resolved[slotName] = componentsById[slotName]
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const bare = slotName.slice(`${APP_SLOT_PREFIX}:`.length)
|
|
101
|
+
if (componentsById[bare]) resolved[slotName] = componentsById[bare]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return resolved
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Full provider slot map for a page request: app chrome and page content.
|
|
109
|
+
* Feature components register at canonical ADR 0006 ids (`@ossy/…/view|form|…`).
|
|
110
|
+
*
|
|
111
|
+
* @param {{
|
|
112
|
+
* layoutSlots?: Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec> | null,
|
|
113
|
+
* componentsById?: Record<string, import('react').ComponentType>,
|
|
114
|
+
* pageComponent?: import('react').ComponentType | null,
|
|
115
|
+
* }} options
|
|
116
|
+
* @returns {Record<string, import('react').ComponentType>}
|
|
117
|
+
*/
|
|
118
|
+
export function resolvePageSlots ({ layoutSlots, componentsById = {}, pageComponent = null } = {}) {
|
|
119
|
+
const appSlots = resolveAppSlots(layoutSlots, componentsById)
|
|
120
|
+
/** @type {Record<string, import('react').ComponentType>} */
|
|
121
|
+
const resolved = { ...componentsById, ...appSlots }
|
|
122
|
+
if (pageComponent) {
|
|
123
|
+
resolved[CONTENT_SLOT_NAME] = pageComponent
|
|
124
|
+
}
|
|
125
|
+
return resolved
|
|
126
|
+
}
|
|
@@ -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,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive MCP tool name from platform action id.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* actionIdToToolName('@ossy/resources/actions/list') // 'ossy_resources_list'
|
|
6
|
+
*
|
|
7
|
+
* @param {string} actionId
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function actionIdToToolName (actionId) {
|
|
11
|
+
const parts = actionId.split('/')
|
|
12
|
+
if (parts.length >= 4 && parts[0].startsWith('@') && parts[2] === 'actions') {
|
|
13
|
+
const provider = parts[0].slice(1)
|
|
14
|
+
return `${provider}_${parts[1]}_${parts[3]}`.replace(/-/g, '_')
|
|
15
|
+
}
|
|
16
|
+
return `ossy_${actionId.replace(/^@/, '').replace(/\//g, '_').replace(/-/g, '_')}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} actionId
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function humanizeActionId (actionId) {
|
|
24
|
+
const intent = actionId.split('/').pop() || actionId
|
|
25
|
+
return intent
|
|
26
|
+
.split('-')
|
|
27
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
28
|
+
.join(' ')
|
|
29
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { templatesContentOneOf } from './template-to-json-schema.js'
|
|
2
|
+
|
|
3
|
+
const workspaceIdProp = {
|
|
4
|
+
workspaceId: {
|
|
5
|
+
type: 'string',
|
|
6
|
+
description: 'Workspace id (defaults to request workspace header)',
|
|
7
|
+
},
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @type {Record<string, (ctx: { schemas: object[] }) => object>} */
|
|
11
|
+
const ENVELOPES = {
|
|
12
|
+
'@ossy/resources/actions/list': () => ({
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
...workspaceIdProp,
|
|
16
|
+
location: { type: 'string', description: 'Folder path, e.g. /test/' },
|
|
17
|
+
query: { type: 'object', description: 'Optional query filters merged with location' },
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
|
|
21
|
+
'@ossy/resources/actions/get': () => ({
|
|
22
|
+
type: 'object',
|
|
23
|
+
required: ['resourceId'],
|
|
24
|
+
properties: {
|
|
25
|
+
...workspaceIdProp,
|
|
26
|
+
resourceId: { type: 'string' },
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
'@ossy/resources/actions/search': () => ({
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
...workspaceIdProp,
|
|
34
|
+
location: { type: 'string' },
|
|
35
|
+
name: { type: 'string' },
|
|
36
|
+
type: { type: 'string', description: 'Resource template id or mime type' },
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
'@ossy/resources/actions/create': ({ schemas }) => {
|
|
41
|
+
const contentSchema = templatesContentOneOf(schemas)
|
|
42
|
+
return {
|
|
43
|
+
type: 'object',
|
|
44
|
+
required: ['location', 'name', 'type'],
|
|
45
|
+
properties: {
|
|
46
|
+
...workspaceIdProp,
|
|
47
|
+
location: { type: 'string', description: 'Parent folder path, e.g. /test/' },
|
|
48
|
+
name: { type: 'string' },
|
|
49
|
+
type: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'directory | mime type (binary) | registered resource template id',
|
|
52
|
+
},
|
|
53
|
+
size: {
|
|
54
|
+
type: 'number',
|
|
55
|
+
description: 'Byte size — required when type is a mime type (binary upload)',
|
|
56
|
+
},
|
|
57
|
+
...(contentSchema
|
|
58
|
+
? {
|
|
59
|
+
content: {
|
|
60
|
+
description: 'Document payload when type is a resource template id',
|
|
61
|
+
...contentSchema,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
: {}),
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
'@ossy/resources/actions/update-content': ({ schemas }) => {
|
|
70
|
+
const contentSchema = templatesContentOneOf(schemas)
|
|
71
|
+
return {
|
|
72
|
+
type: 'object',
|
|
73
|
+
required: ['resourceId', 'content'],
|
|
74
|
+
properties: {
|
|
75
|
+
...workspaceIdProp,
|
|
76
|
+
resourceId: { type: 'string' },
|
|
77
|
+
...(contentSchema
|
|
78
|
+
? { content: contentSchema }
|
|
79
|
+
: { content: { type: 'object' } }),
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
'@ossy/resources/actions/delete': () => ({
|
|
85
|
+
type: 'object',
|
|
86
|
+
required: ['resourceId'],
|
|
87
|
+
properties: {
|
|
88
|
+
...workspaceIdProp,
|
|
89
|
+
resourceId: { type: 'string' },
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
|
|
93
|
+
'@ossy/resources/actions/upload-named-version': () => ({
|
|
94
|
+
type: 'object',
|
|
95
|
+
required: ['resourceId', 'namedVersion', 'type', 'size'],
|
|
96
|
+
properties: {
|
|
97
|
+
...workspaceIdProp,
|
|
98
|
+
resourceId: { type: 'string' },
|
|
99
|
+
namedVersion: { type: 'string', description: 'Version label, e.g. thumbnail' },
|
|
100
|
+
type: { type: 'string', description: 'Mime type' },
|
|
101
|
+
size: { type: 'number' },
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
|
|
105
|
+
'@ossy/resources/actions/update-name': () => ({
|
|
106
|
+
type: 'object',
|
|
107
|
+
required: ['resourceId', 'name'],
|
|
108
|
+
properties: {
|
|
109
|
+
...workspaceIdProp,
|
|
110
|
+
resourceId: { type: 'string' },
|
|
111
|
+
name: { type: 'string' },
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
|
|
115
|
+
'@ossy/resources/actions/update-location': () => ({
|
|
116
|
+
type: 'object',
|
|
117
|
+
required: ['resourceId', 'target'],
|
|
118
|
+
properties: {
|
|
119
|
+
...workspaceIdProp,
|
|
120
|
+
resourceId: { type: 'string' },
|
|
121
|
+
target: { type: 'string', description: 'Destination folder path' },
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
|
|
125
|
+
'@ossy/resources/actions/update-access': () => ({
|
|
126
|
+
type: 'object',
|
|
127
|
+
required: ['resourceId', 'access'],
|
|
128
|
+
properties: {
|
|
129
|
+
...workspaceIdProp,
|
|
130
|
+
resourceId: { type: 'string' },
|
|
131
|
+
access: { type: 'string', enum: ['public', 'workspace', 'restricted'] },
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
|
|
135
|
+
'@ossy/booking/actions/get-services': () => ({
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
providerSlug: { type: 'string' },
|
|
139
|
+
workspaceId: { type: 'string' },
|
|
140
|
+
includeInactive: { type: 'boolean' },
|
|
141
|
+
includeContact: { type: 'boolean' },
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
|
|
145
|
+
'@ossy/booking/actions/get-available-slots': () => ({
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
providerSlug: { type: 'string' },
|
|
149
|
+
workspaceId: { type: 'string' },
|
|
150
|
+
serviceId: { type: 'string' },
|
|
151
|
+
duration: { type: 'number', description: 'Slot duration in minutes' },
|
|
152
|
+
from: { type: 'number', description: 'Range start (Unix ms)' },
|
|
153
|
+
to: { type: 'number', description: 'Range end (Unix ms)' },
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
|
|
157
|
+
'@ossy/booking/actions/create': () => ({
|
|
158
|
+
type: 'object',
|
|
159
|
+
required: ['startAt', 'duration', 'clientName', 'clientEmail'],
|
|
160
|
+
properties: {
|
|
161
|
+
providerSlug: { type: 'string' },
|
|
162
|
+
workspaceId: { type: 'string' },
|
|
163
|
+
serviceId: { type: 'string' },
|
|
164
|
+
startAt: { type: 'number', description: 'Slot start (Unix ms)' },
|
|
165
|
+
duration: { type: 'number', description: 'Duration in minutes' },
|
|
166
|
+
clientName: { type: 'string' },
|
|
167
|
+
clientEmail: { type: 'string', format: 'email' },
|
|
168
|
+
clientMessage: { type: 'string' },
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
|
|
172
|
+
'@ossy/workspaces/actions/get-schemas': () => ({
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: { ...workspaceIdProp },
|
|
175
|
+
}),
|
|
176
|
+
|
|
177
|
+
'@ossy/workspaces/actions/import-schemas': () => ({
|
|
178
|
+
type: 'object',
|
|
179
|
+
required: ['schemas'],
|
|
180
|
+
properties: {
|
|
181
|
+
...workspaceIdProp,
|
|
182
|
+
schemas: {
|
|
183
|
+
type: 'array',
|
|
184
|
+
items: { type: 'object' },
|
|
185
|
+
description: 'Workspace-imported schema definitions',
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
|
|
190
|
+
'@ossy/users/actions/create-api-token': () => ({
|
|
191
|
+
type: 'object',
|
|
192
|
+
required: ['name', 'description'],
|
|
193
|
+
properties: {
|
|
194
|
+
name: { type: 'string' },
|
|
195
|
+
description: { type: 'string' },
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} actionId
|
|
202
|
+
* @param {{ schemas?: object[] }} [ctx]
|
|
203
|
+
* @returns {object}
|
|
204
|
+
*/
|
|
205
|
+
export function buildActionInputSchema (actionId, ctx = {}) {
|
|
206
|
+
const schemas = ctx.schemas || []
|
|
207
|
+
const builder = ENVELOPES[actionId]
|
|
208
|
+
if (builder) {
|
|
209
|
+
return builder({ schemas })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
type: 'object',
|
|
214
|
+
properties: {
|
|
215
|
+
...workspaceIdProp,
|
|
216
|
+
payload: { type: 'object', description: 'Action-specific payload' },
|
|
217
|
+
},
|
|
218
|
+
additionalProperties: true,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { buildCapabilities, capabilitiesToActionsSchema } from './build-capabilities.js'
|
|
2
|
+
import { taskIdFromActionId } from '@ossy/schema'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {Array<{ id: string, access?: string, package?: string }>} actions
|
|
6
|
+
* @param {{ schemas?: object[], tasks?: object[], taskCatalog?: object[], taskGraphEdges?: object[], generatedAt?: string }} [options]
|
|
7
|
+
* @returns {{ version: number, generatedAt: string, actions: Array<object> }}
|
|
8
|
+
*/
|
|
9
|
+
export function buildActionsSchema (actions = [], options = {}) {
|
|
10
|
+
const capabilities = buildCapabilities(
|
|
11
|
+
{
|
|
12
|
+
actions,
|
|
13
|
+
tasks: options.tasks || actions.flatMap(a => {
|
|
14
|
+
try {
|
|
15
|
+
return [{ id: taskIdFromActionId(a.id) }]
|
|
16
|
+
} catch {
|
|
17
|
+
return []
|
|
18
|
+
}
|
|
19
|
+
}),
|
|
20
|
+
schemas: options.schemas || [],
|
|
21
|
+
taskCatalog: options.taskCatalog || [],
|
|
22
|
+
taskGraphEdges: options.taskGraphEdges || [],
|
|
23
|
+
},
|
|
24
|
+
{ generatedAt: options.generatedAt },
|
|
25
|
+
)
|
|
26
|
+
return capabilitiesToActionsSchema(capabilities, actions)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
buildCapabilities,
|
|
31
|
+
buildCapabilitiesGraph,
|
|
32
|
+
capabilitiesTaskTopologyResource,
|
|
33
|
+
capabilitiesToActionsSchema,
|
|
34
|
+
projectTaskForCapabilities,
|
|
35
|
+
TASK_TOPOLOGY_RESOURCE_URI,
|
|
36
|
+
} from './build-capabilities.js'
|