@ossy/app 1.39.6 → 1.40.0
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 +13 -10
- package/cli/build.task.js +28 -12
- package/cli/discover-translation-files.js +139 -0
- package/cli/get-platform-files.task.js +1 -0
- package/cli/manifest-plugin.js +81 -26
- package/cli/merge-translations.task.js +170 -0
- package/package.json +15 -10
- package/runtime/page-runtime.js +53 -8
- package/runtime/resolve-shell-slots.js +112 -0
- package/src/manifest/build-manifest-summary.js +115 -0
- package/src/manifest/discover-package-definitions.js +100 -0
- package/src/manifest/serialize-package-definition.js +30 -0
- package/src/shell/App.jsx +22 -14
- package/src/shell/AppSettings.jsx +2 -0
- package/src/shell/DevPagesPanel.jsx +128 -0
- package/src/shell/ThemeEditor.jsx +36 -65
- package/src/shell/devPagesUtils.js +53 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { mergeTranslationFiles, pageIdToDocumentTitleKey } from '@ossy/locale'
|
|
5
|
+
import {
|
|
6
|
+
discoverTranslationFiles,
|
|
7
|
+
validateTranslationLocales,
|
|
8
|
+
} from './discover-translation-files.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Array<{ type: string, id: string, path?: string | Record<string, string> }>} entries
|
|
12
|
+
* @param {string[]} supportedLanguages
|
|
13
|
+
* @param {string | undefined} defaultLanguage
|
|
14
|
+
* @param {(message: string) => void} error
|
|
15
|
+
* @param {(message: string) => void} warn
|
|
16
|
+
*/
|
|
17
|
+
export function validateLanguageConfig ({ entries, supportedLanguages, defaultLanguage, error, warn }) {
|
|
18
|
+
if (!Array.isArray(supportedLanguages) || supportedLanguages.length === 0) return
|
|
19
|
+
|
|
20
|
+
if (defaultLanguage && !supportedLanguages.includes(defaultLanguage)) {
|
|
21
|
+
error(
|
|
22
|
+
`[@ossy/app][build] config.defaultLanguage "${defaultLanguage}" is not in supportedLanguages`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const entry of entries.filter((e) => e.type === 'page')) {
|
|
27
|
+
if (typeof entry.path !== 'object' || !entry.path) continue
|
|
28
|
+
|
|
29
|
+
for (const lang of Object.keys(entry.path)) {
|
|
30
|
+
if (!supportedLanguages.includes(lang)) {
|
|
31
|
+
error(
|
|
32
|
+
`[@ossy/app][build] Page "${entry.id}" metadata.path key "${lang}" is not in supportedLanguages`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const lang of supportedLanguages) {
|
|
38
|
+
if (!(lang in entry.path)) {
|
|
39
|
+
warn(
|
|
40
|
+
`[@ossy/app][build] Page "${entry.id}" metadata.path is missing key for supported language "${lang}"`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Array<{ type: string, id: string, title?: string }>} entries
|
|
49
|
+
* @param {Record<string, Record<string, string>>} catalogs
|
|
50
|
+
* @param {string | undefined} defaultLanguage
|
|
51
|
+
* @param {(message: string) => void} warn
|
|
52
|
+
*/
|
|
53
|
+
export function validatePageDocumentTitles ({ entries, catalogs, defaultLanguage, warn }) {
|
|
54
|
+
if (!defaultLanguage) return
|
|
55
|
+
const catalog = catalogs[defaultLanguage] || {}
|
|
56
|
+
|
|
57
|
+
for (const entry of entries.filter((e) => e.type === 'page')) {
|
|
58
|
+
if (entry.title) continue
|
|
59
|
+
const key = pageIdToDocumentTitleKey(entry.id)
|
|
60
|
+
if (!catalog[key]) {
|
|
61
|
+
warn(
|
|
62
|
+
`[@ossy/app][build] Page "${entry.id}" document title key "${key}" missing in default language "${defaultLanguage}" catalog`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {{ actions?: Array<{ id: string }> }} manifest
|
|
70
|
+
* @param {Record<string, Record<string, string>>} catalogs
|
|
71
|
+
* @param {string | undefined} defaultLanguage
|
|
72
|
+
* @param {(message: string) => void} warn
|
|
73
|
+
*/
|
|
74
|
+
export function validateActionTranslationKeys ({ manifest, catalogs, defaultLanguage, warn }) {
|
|
75
|
+
if (!defaultLanguage) return
|
|
76
|
+
const catalog = catalogs[defaultLanguage] || {}
|
|
77
|
+
const actions = Array.isArray(manifest?.actions) ? manifest.actions : []
|
|
78
|
+
|
|
79
|
+
for (const action of actions) {
|
|
80
|
+
const actionId = action.id
|
|
81
|
+
if (!actionId) continue
|
|
82
|
+
if (!catalog[`${actionId}.label`]) {
|
|
83
|
+
warn(
|
|
84
|
+
`[@ossy/app][build] Action "${actionId}" missing "${actionId}.label" in default language catalog`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
if (!catalog[`${actionId}.description`]) {
|
|
88
|
+
warn(
|
|
89
|
+
`[@ossy/app][build] Action "${actionId}" missing "${actionId}.description" in default language catalog`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Discover, merge, validate, and emit per-locale translation catalogs.
|
|
97
|
+
*
|
|
98
|
+
* @param {{
|
|
99
|
+
* srcDir: string
|
|
100
|
+
* projectRoot: string
|
|
101
|
+
* publicOutDir: string
|
|
102
|
+
* manifestPath: string
|
|
103
|
+
* configValue: object
|
|
104
|
+
* log?: (message: string) => void
|
|
105
|
+
* warn?: (message: string) => void
|
|
106
|
+
* }} options
|
|
107
|
+
*/
|
|
108
|
+
export function mergeAndEmitTranslations ({
|
|
109
|
+
srcDir,
|
|
110
|
+
projectRoot,
|
|
111
|
+
publicOutDir,
|
|
112
|
+
manifestPath,
|
|
113
|
+
configValue,
|
|
114
|
+
log = () => {},
|
|
115
|
+
warn = (message) => log(message),
|
|
116
|
+
}) {
|
|
117
|
+
const supportedLanguages = Array.isArray(configValue?.supportedLanguages)
|
|
118
|
+
? configValue.supportedLanguages
|
|
119
|
+
: []
|
|
120
|
+
const defaultLanguage = typeof configValue?.defaultLanguage === 'string'
|
|
121
|
+
? configValue.defaultLanguage
|
|
122
|
+
: undefined
|
|
123
|
+
|
|
124
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
125
|
+
const entries = Array.isArray(manifest.entries) ? manifest.entries : []
|
|
126
|
+
|
|
127
|
+
/** @type {string[]} */
|
|
128
|
+
const errors = []
|
|
129
|
+
validateLanguageConfig({
|
|
130
|
+
entries,
|
|
131
|
+
supportedLanguages,
|
|
132
|
+
defaultLanguage,
|
|
133
|
+
error: (message) => errors.push(message),
|
|
134
|
+
warn,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const discovered = discoverTranslationFiles({ srcDir, projectRoot })
|
|
138
|
+
validateTranslationLocales(discovered)
|
|
139
|
+
|
|
140
|
+
const catalogs = mergeTranslationFiles(discovered, supportedLanguages, {
|
|
141
|
+
readFileSync: (filePath, encoding) => fs.readFileSync(filePath, encoding),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
for (const lang of supportedLanguages) {
|
|
145
|
+
const keys = Object.keys(catalogs[lang] || {})
|
|
146
|
+
if (keys.length === 0) {
|
|
147
|
+
warn(
|
|
148
|
+
`[@ossy/app][build] No translation keys merged for supported language "${lang}"`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
validatePageDocumentTitles({ entries, catalogs, defaultLanguage, warn })
|
|
154
|
+
validateActionTranslationKeys({ manifest, catalogs, defaultLanguage, warn })
|
|
155
|
+
|
|
156
|
+
/** @type {Record<string, string>} */
|
|
157
|
+
const files = {}
|
|
158
|
+
for (const lang of supportedLanguages) {
|
|
159
|
+
const outPath = path.join(publicOutDir, `${lang}.translations.json`)
|
|
160
|
+
fs.writeFileSync(outPath, JSON.stringify(catalogs[lang] || {}, null, 2) + '\n', 'utf8')
|
|
161
|
+
files[lang] = `/${lang}.translations.json`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (errors.length > 0) {
|
|
165
|
+
throw new Error(errors.join('\n'))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
manifest.translations = { files }
|
|
169
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
|
|
170
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"source": "./src/index.js",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
".": "./src/index.js",
|
|
10
10
|
"./shell": "./src/shell/index.js",
|
|
11
11
|
"./runtime/page-runtime": "./runtime/page-runtime.js",
|
|
12
|
+
"./runtime/resolve-shell-slots": "./runtime/resolve-shell-slots.js",
|
|
12
13
|
"./runtime/api-runtime": "./runtime/api-runtime.js",
|
|
13
|
-
"./runtime/task-runtime": "./runtime/task-runtime.js"
|
|
14
|
+
"./runtime/task-runtime": "./runtime/task-runtime.js",
|
|
15
|
+
"./manifest/build-manifest-summary": "./src/manifest/build-manifest-summary.js",
|
|
16
|
+
"./manifest/discover-package-definitions": "./src/manifest/discover-package-definitions.js",
|
|
17
|
+
"./manifest/serialize-package-definition": "./src/manifest/serialize-package-definition.js"
|
|
14
18
|
},
|
|
15
19
|
"bin": {
|
|
16
20
|
"app": "./cli/index.js"
|
|
@@ -38,14 +42,15 @@
|
|
|
38
42
|
"@babel/eslint-parser": "^7.15.8",
|
|
39
43
|
"@babel/preset-react": "^7.26.3",
|
|
40
44
|
"@babel/register": "^7.25.9",
|
|
41
|
-
"@ossy/design-system": "^1.
|
|
45
|
+
"@ossy/design-system": "^1.40.0",
|
|
46
|
+
"@ossy/locale": "^1.40.0",
|
|
42
47
|
"@ossy/pages": "^1.23.0",
|
|
43
|
-
"@ossy/platform": "^1.
|
|
44
|
-
"@ossy/router": "^1.
|
|
45
|
-
"@ossy/router-react": "^1.
|
|
46
|
-
"@ossy/sdk": "^1.
|
|
47
|
-
"@ossy/sdk-react": "^1.
|
|
48
|
-
"@ossy/themes": "^1.
|
|
48
|
+
"@ossy/platform": "^1.39.0",
|
|
49
|
+
"@ossy/router": "^1.40.0",
|
|
50
|
+
"@ossy/router-react": "^1.40.0",
|
|
51
|
+
"@ossy/sdk": "^1.40.0",
|
|
52
|
+
"@ossy/sdk-react": "^1.40.0",
|
|
53
|
+
"@ossy/themes": "^1.40.0",
|
|
49
54
|
"@rollup/plugin-alias": "^6.0.0",
|
|
50
55
|
"@rollup/plugin-babel": "^7.0.0",
|
|
51
56
|
"@rollup/plugin-commonjs": "^29.0.0",
|
|
@@ -79,5 +84,5 @@
|
|
|
79
84
|
"README.md",
|
|
80
85
|
"tsconfig.json"
|
|
81
86
|
],
|
|
82
|
-
"gitHead": "
|
|
87
|
+
"gitHead": "d9d31182be64a448d575da432af2830d909ad4b9"
|
|
83
88
|
}
|
package/runtime/page-runtime.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createElement } from 'react'
|
|
2
|
+
import { pageIdToDocumentTitleKey, resolveMessage } from '@ossy/locale'
|
|
2
3
|
import { App } from '../src/shell/App.jsx'
|
|
4
|
+
import { resolvePageSlots } from './resolve-shell-slots.js'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Dynamically imports each component bundle listed in `entries` and returns a
|
|
@@ -40,13 +42,42 @@ export async function loadLayout (layoutEntry) {
|
|
|
40
42
|
try {
|
|
41
43
|
const mod = await import(layoutEntry)
|
|
42
44
|
return mod?.default ?? null
|
|
43
|
-
} catch {
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
47
|
+
console.error('[@ossy/app][page-runtime] Failed to load layout bundle:', layoutEntry, err)
|
|
48
|
+
}
|
|
44
49
|
return null
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
function resolveDocumentTitle (metadata, props) {
|
|
54
|
+
if (metadata.id && props.messages) {
|
|
55
|
+
const key = pageIdToDocumentTitleKey(metadata.id)
|
|
56
|
+
const hasTranslation =
|
|
57
|
+
props.messages[key] != null || props.fallbackMessages?.[key] != null
|
|
58
|
+
if (hasTranslation) {
|
|
59
|
+
return resolveMessage(props.messages, key, {
|
|
60
|
+
fallbackCatalog: props.fallbackMessages,
|
|
61
|
+
onMissingKey: (missingKey) => {
|
|
62
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
63
|
+
console.warn(
|
|
64
|
+
`[@ossy/app][page-runtime] Missing document title key "${missingKey}" for page "${metadata.id}"`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
71
|
+
console.warn(
|
|
72
|
+
`[@ossy/app][page-runtime] Missing document title key "${key}" for page "${metadata.id}"; falling back to metadata.title`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return metadata.title || props.documentTitle || ''
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
function buildTree ({ Component, Layout, metadata, props }) {
|
|
49
|
-
const lang = props.
|
|
80
|
+
const lang = props.language || props.defaultLanguage || 'en'
|
|
50
81
|
const pageEl = createElement(Component, props)
|
|
51
82
|
const contentEl = Layout ? createElement(Layout, props, pageEl) : pageEl
|
|
52
83
|
return createElement(
|
|
@@ -56,9 +87,9 @@ function buildTree ({ Component, Layout, metadata, props }) {
|
|
|
56
87
|
'head',
|
|
57
88
|
null,
|
|
58
89
|
createElement('meta', { charSet: 'utf-8' }),
|
|
59
|
-
createElement('title', null, metadata
|
|
90
|
+
createElement('title', null, resolveDocumentTitle(metadata, props)),
|
|
60
91
|
),
|
|
61
|
-
createElement(App, props, contentEl),
|
|
92
|
+
createElement('body', null, createElement(App, props, contentEl)),
|
|
62
93
|
)
|
|
63
94
|
}
|
|
64
95
|
|
|
@@ -96,8 +127,13 @@ export function createPageEntry (pageModule, options = {}) {
|
|
|
96
127
|
import('node:stream'),
|
|
97
128
|
])
|
|
98
129
|
|
|
99
|
-
const { Layout = null, componentEntries = [], ...pageProps } = props
|
|
100
|
-
const
|
|
130
|
+
const { Layout = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
|
|
131
|
+
const componentsById = await loadComponents(componentEntries)
|
|
132
|
+
const components = resolvePageSlots({
|
|
133
|
+
layoutSlots,
|
|
134
|
+
componentsById,
|
|
135
|
+
pageComponent: Component,
|
|
136
|
+
})
|
|
101
137
|
const tree = buildTree({ Component, Layout, metadata, props: { ...pageProps, components } })
|
|
102
138
|
const bootstrapUrl = toBootstrapUrl(entryUrl)
|
|
103
139
|
const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
|
|
@@ -133,13 +169,22 @@ export function createPageEntry (pageModule, options = {}) {
|
|
|
133
169
|
if (typeof document === 'undefined' || typeof window === 'undefined') return
|
|
134
170
|
hydrated = true
|
|
135
171
|
const props = window.__OSSY__ || {}
|
|
136
|
-
const { layoutEntry = null, componentEntries = [], ...pageProps } = props
|
|
172
|
+
const { layoutEntry = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
|
|
137
173
|
Promise.all([
|
|
138
174
|
import('react-dom/client'),
|
|
139
175
|
loadComponents(componentEntries),
|
|
140
176
|
loadLayout(layoutEntry),
|
|
141
|
-
]).then(([{ hydrateRoot },
|
|
177
|
+
]).then(([{ hydrateRoot }, componentsById, Layout]) => {
|
|
178
|
+
const components = resolvePageSlots({
|
|
179
|
+
layoutSlots,
|
|
180
|
+
componentsById,
|
|
181
|
+
pageComponent: Component,
|
|
182
|
+
})
|
|
142
183
|
hydrateRoot(document, buildTree({ Component, Layout, metadata, props: { ...pageProps, components } }))
|
|
184
|
+
}).catch((err) => {
|
|
185
|
+
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
186
|
+
console.error('[@ossy/app][page-runtime] Hydration failed:', err)
|
|
187
|
+
}
|
|
143
188
|
})
|
|
144
189
|
}
|
|
145
190
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Canonical shell slot names (namespaced). App layout maps chrome only — not content. */
|
|
2
|
+
export const SHELL_SLOT_NAMES = [
|
|
3
|
+
'shell:header',
|
|
4
|
+
'shell:sidebar',
|
|
5
|
+
'shell:toolbar',
|
|
6
|
+
'shell:notifications',
|
|
7
|
+
'shell:system-messages',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
/** Platform-owned slot filled with the current route page component. */
|
|
11
|
+
export const CONTENT_SLOT_NAME = 'shell:content'
|
|
12
|
+
|
|
13
|
+
/** Bare shell region → namespaced key (legacy fallback for app components only). */
|
|
14
|
+
const BARE_SHELL_REGION = {
|
|
15
|
+
header: 'shell:header',
|
|
16
|
+
sidebar: 'shell:sidebar',
|
|
17
|
+
toolbar: 'shell:toolbar',
|
|
18
|
+
notifications: 'shell:notifications',
|
|
19
|
+
'system-messages': 'shell:system-messages',
|
|
20
|
+
content: CONTENT_SLOT_NAME,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalize a slot map key to the canonical namespaced form when it is a bare shell region.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} slotName
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeShellSlotName (slotName) {
|
|
30
|
+
const key = typeof slotName === 'string' ? slotName.trim() : ''
|
|
31
|
+
return BARE_SHELL_REGION[key] ?? key
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build `Record<slotName, Component>` from the app layout's static `slots` map
|
|
36
|
+
* (`slotName → componentId`) and components loaded by `metadata.id`.
|
|
37
|
+
*
|
|
38
|
+
* One component per slot. Optional fallback (app components only): if the layout
|
|
39
|
+
* map omits a slot, use a component whose id equals the slot name (e.g. `shell:header`)
|
|
40
|
+
* or the bare region name (e.g. `header`).
|
|
41
|
+
*
|
|
42
|
+
* Does not resolve `shell:content` — use {@link resolvePageSlots}.
|
|
43
|
+
*
|
|
44
|
+
* @param {Record<string, string> | null | undefined} layoutSlotsMap
|
|
45
|
+
* @param {Record<string, import('react').ComponentType>} componentsById
|
|
46
|
+
* @returns {Record<string, import('react').ComponentType>}
|
|
47
|
+
*/
|
|
48
|
+
export function resolveShellSlots (layoutSlotsMap, componentsById) {
|
|
49
|
+
/** @type {Record<string, import('react').ComponentType>} */
|
|
50
|
+
const resolved = {}
|
|
51
|
+
|
|
52
|
+
const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
|
|
53
|
+
for (const [rawSlot, componentId] of Object.entries(map)) {
|
|
54
|
+
const slotName = normalizeShellSlotName(rawSlot)
|
|
55
|
+
const id = typeof componentId === 'string' ? componentId.trim() : ''
|
|
56
|
+
if (!slotName || !id || slotName === CONTENT_SLOT_NAME) continue
|
|
57
|
+
const Component = componentsById[id]
|
|
58
|
+
if (Component) resolved[slotName] = Component
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const slotName of SHELL_SLOT_NAMES) {
|
|
62
|
+
if (resolved[slotName]) continue
|
|
63
|
+
if (componentsById[slotName]) {
|
|
64
|
+
resolved[slotName] = componentsById[slotName]
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const bare = slotName.slice('shell:'.length)
|
|
68
|
+
if (componentsById[bare]) resolved[slotName] = componentsById[bare]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return resolved
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const RESOURCE_SLOT_PREFIX = 'resource:'
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Map manifest components whose `metadata.id` is a `resource:{type}/{view}` key.
|
|
78
|
+
*
|
|
79
|
+
* @param {Record<string, import('react').ComponentType>} componentsById
|
|
80
|
+
* @returns {Record<string, import('react').ComponentType>}
|
|
81
|
+
*/
|
|
82
|
+
export function resolveResourceSlots (componentsById) {
|
|
83
|
+
/** @type {Record<string, import('react').ComponentType>} */
|
|
84
|
+
const resolved = {}
|
|
85
|
+
for (const [id, Component] of Object.entries(componentsById || {})) {
|
|
86
|
+
if (typeof id === 'string' && id.startsWith(RESOURCE_SLOT_PREFIX) && Component) {
|
|
87
|
+
resolved[id] = Component
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return resolved
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Full provider slot map for a page request: shell chrome, resource views, and page content.
|
|
95
|
+
*
|
|
96
|
+
* @param {{
|
|
97
|
+
* layoutSlots?: Record<string, string> | null,
|
|
98
|
+
* componentsById?: Record<string, import('react').ComponentType>,
|
|
99
|
+
* pageComponent?: import('react').ComponentType | null,
|
|
100
|
+
* }} options
|
|
101
|
+
* @returns {Record<string, import('react').ComponentType>}
|
|
102
|
+
*/
|
|
103
|
+
export function resolvePageSlots ({ layoutSlots, componentsById = {}, pageComponent = null } = {}) {
|
|
104
|
+
const shellSlots = resolveShellSlots(layoutSlots, componentsById)
|
|
105
|
+
const resourceSlots = resolveResourceSlots(componentsById)
|
|
106
|
+
/** @type {Record<string, import('react').ComponentType>} */
|
|
107
|
+
const resolved = { ...componentsById, ...shellSlots, ...resourceSlots }
|
|
108
|
+
if (pageComponent) {
|
|
109
|
+
resolved[CONTENT_SLOT_NAME] = pageComponent
|
|
110
|
+
}
|
|
111
|
+
return resolved
|
|
112
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} packageName npm package name (e.g. `@ossy/booking`).
|
|
3
|
+
* @returns {string} URL slug (e.g. `booking`).
|
|
4
|
+
*/
|
|
5
|
+
export function packageNameToSlug (packageName) {
|
|
6
|
+
const slash = packageName.indexOf('/')
|
|
7
|
+
return slash === -1 ? packageName : packageName.slice(slash + 1)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} slug URL slug (e.g. `booking`).
|
|
12
|
+
* @returns {string} Scoped package name (e.g. `@ossy/booking`).
|
|
13
|
+
*/
|
|
14
|
+
export function slugToPackageName (slug) {
|
|
15
|
+
return `@ossy/${slug}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} manifest Loaded manifest from `@ossy/platform` `loadManifest`.
|
|
20
|
+
* @returns {{ packages: Array<object> }} Grouped, JSON-serializable package summary.
|
|
21
|
+
*/
|
|
22
|
+
export function buildManifestSummary (manifest) {
|
|
23
|
+
/** @type {Map<string, object>} */
|
|
24
|
+
const groups = new Map()
|
|
25
|
+
|
|
26
|
+
const ensure = (pkg) => {
|
|
27
|
+
if (!groups.has(pkg)) {
|
|
28
|
+
groups.set(pkg, {
|
|
29
|
+
package: pkg,
|
|
30
|
+
slug: packageNameToSlug(pkg),
|
|
31
|
+
pages: [],
|
|
32
|
+
apis: [],
|
|
33
|
+
actions: [],
|
|
34
|
+
components: [],
|
|
35
|
+
resourceTemplates: [],
|
|
36
|
+
tasks: [],
|
|
37
|
+
integrations: [],
|
|
38
|
+
emails: [],
|
|
39
|
+
aggregates: [],
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
return groups.get(pkg)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const add = (pkg, key, item) => {
|
|
46
|
+
ensure(pkg || '@ossy/app')[key].push(item)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const page of manifest.pages || []) {
|
|
50
|
+
add(page.package, 'pages', {
|
|
51
|
+
id: page.id,
|
|
52
|
+
path: page.path,
|
|
53
|
+
...(page.title ? { title: page.title } : {}),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const api of manifest.apis || []) {
|
|
58
|
+
add(api.package, 'apis', { id: api.id, path: api.path })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const action of manifest.actions || []) {
|
|
62
|
+
add(action.package, 'actions', {
|
|
63
|
+
id: action.id,
|
|
64
|
+
...(action.access ? { access: action.access } : {}),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const component of manifest.components || []) {
|
|
69
|
+
add(component.package, 'components', { id: component.id })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const template of manifest.resourceTemplates || []) {
|
|
73
|
+
add(template.package, 'resourceTemplates', {
|
|
74
|
+
id: template.id,
|
|
75
|
+
...(template.title ? { title: template.title } : {}),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const task of manifest.tasks || []) {
|
|
80
|
+
add(task.package, 'tasks', { id: task.id })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const integration of manifest.integrations || []) {
|
|
84
|
+
add(integration.package, 'integrations', {
|
|
85
|
+
id: integration.id,
|
|
86
|
+
...(integration.credentials?.length ? { credentials: integration.credentials } : {}),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const email of manifest.emails || []) {
|
|
91
|
+
add(email.package, 'emails', { id: email.id })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const aggregate of manifest.aggregates || []) {
|
|
95
|
+
add(aggregate.package, 'aggregates', { id: aggregate.id })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const packages = Array.from(groups.values())
|
|
99
|
+
.sort((a, b) => a.package.localeCompare(b.package))
|
|
100
|
+
|
|
101
|
+
for (const pkg of packages) {
|
|
102
|
+
for (const key of Object.keys(pkg)) {
|
|
103
|
+
if (Array.isArray(pkg[key])) {
|
|
104
|
+
pkg[key].sort((a, b) => a.id.localeCompare(b.id))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const definitions =
|
|
110
|
+
manifest.definitions && typeof manifest.definitions === 'object' && !Array.isArray(manifest.definitions)
|
|
111
|
+
? manifest.definitions
|
|
112
|
+
: {}
|
|
113
|
+
|
|
114
|
+
return { packages, definitions }
|
|
115
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import { packageNameToSlug } from './build-manifest-summary.js'
|
|
6
|
+
import { serializePackageDefinition } from './serialize-package-definition.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} projectRoot
|
|
10
|
+
* @returns {string[]}
|
|
11
|
+
*/
|
|
12
|
+
function collectNodeModulesDirs (projectRoot) {
|
|
13
|
+
const dirs = []
|
|
14
|
+
let current = projectRoot
|
|
15
|
+
while (true) {
|
|
16
|
+
const nm = path.join(current, 'node_modules')
|
|
17
|
+
if (fs.existsSync(nm) && fs.statSync(nm).isDirectory()) dirs.push(nm)
|
|
18
|
+
const parent = path.dirname(current)
|
|
19
|
+
if (parent === current) break
|
|
20
|
+
current = parent
|
|
21
|
+
}
|
|
22
|
+
return dirs
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load `Definition.js` from disk only — never import the package main entry,
|
|
27
|
+
* which may pull CLI side effects (e.g. `@ossy/cli` runs on import).
|
|
28
|
+
*
|
|
29
|
+
* @param {string} pkgDir
|
|
30
|
+
* @param {object} pkg
|
|
31
|
+
* @returns {Promise<object | null>}
|
|
32
|
+
*/
|
|
33
|
+
async function loadPackageDefinition (pkgDir, pkg) {
|
|
34
|
+
if (!pkg.ossy?.src) return null
|
|
35
|
+
|
|
36
|
+
const defPath = path.join(pkgDir, pkg.ossy.src, 'Definition.js')
|
|
37
|
+
if (!fs.existsSync(defPath)) return null
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const mod = await import(pathToFileURL(defPath).href + `?t=${Date.now()}`)
|
|
41
|
+
if (mod?.Definition && typeof mod.Definition === 'object') {
|
|
42
|
+
return mod.Definition
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// skip packages without a readable Definition
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Discover `Definition.js` from installed `@ossy/*` packages.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} projectRoot Absolute path to the consuming app root.
|
|
55
|
+
* @returns {Promise<Record<string, object>>} Map keyed by package slug.
|
|
56
|
+
*/
|
|
57
|
+
export async function discoverPackageDefinitions (projectRoot) {
|
|
58
|
+
const nmDirs = collectNodeModulesDirs(projectRoot)
|
|
59
|
+
/** @type {Record<string, object>} */
|
|
60
|
+
const definitions = {}
|
|
61
|
+
const seen = new Set()
|
|
62
|
+
|
|
63
|
+
const tryPackageDir = async (pkgDir) => {
|
|
64
|
+
const real = fs.realpathSync(pkgDir)
|
|
65
|
+
if (seen.has(real)) return
|
|
66
|
+
seen.add(real)
|
|
67
|
+
|
|
68
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json')
|
|
69
|
+
if (!fs.existsSync(pkgJsonPath)) return
|
|
70
|
+
|
|
71
|
+
let pkg
|
|
72
|
+
try {
|
|
73
|
+
pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
|
|
74
|
+
} catch {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const name = typeof pkg.name === 'string' ? pkg.name.trim() : ''
|
|
79
|
+
if (!name.startsWith('@ossy/')) return
|
|
80
|
+
|
|
81
|
+
const slug = packageNameToSlug(name)
|
|
82
|
+
if (!slug || definitions[slug]) return
|
|
83
|
+
|
|
84
|
+
const raw = await loadPackageDefinition(real, pkg)
|
|
85
|
+
const serialized = serializePackageDefinition(raw)
|
|
86
|
+
if (serialized) definitions[slug] = serialized
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const nmDir of nmDirs) {
|
|
90
|
+
const scopeDir = path.join(nmDir, '@ossy')
|
|
91
|
+
if (!fs.existsSync(scopeDir) || !fs.statSync(scopeDir).isDirectory()) continue
|
|
92
|
+
|
|
93
|
+
for (const entry of fs.readdirSync(scopeDir, { withFileTypes: true })) {
|
|
94
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
95
|
+
await tryPackageDir(path.join(scopeDir, entry.name))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return definitions
|
|
100
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick JSON-serializable Definition fields for manifest / SSR bootstrap.
|
|
3
|
+
* Definition holds module presentation metadata only — capabilities live in manifestSummary.
|
|
4
|
+
*
|
|
5
|
+
* @param {object | null | undefined} definition
|
|
6
|
+
* @returns {object | null}
|
|
7
|
+
*/
|
|
8
|
+
export function serializePackageDefinition (definition) {
|
|
9
|
+
if (!definition || typeof definition !== 'object' || Array.isArray(definition)) return null
|
|
10
|
+
|
|
11
|
+
/** @type {Record<string, unknown>} */
|
|
12
|
+
const out = {}
|
|
13
|
+
|
|
14
|
+
if (typeof definition.id === 'string' && definition.id.trim()) out.id = definition.id.trim()
|
|
15
|
+
if (typeof definition.title === 'string' && definition.title.trim()) out.title = definition.title.trim()
|
|
16
|
+
if (typeof definition.description === 'string' && definition.description.trim()) {
|
|
17
|
+
out.description = definition.description.trim()
|
|
18
|
+
}
|
|
19
|
+
if (typeof definition.icon === 'string' && definition.icon.trim()) out.icon = definition.icon.trim()
|
|
20
|
+
if (typeof definition.navOrder === 'number' && Number.isFinite(definition.navOrder)) {
|
|
21
|
+
out.navOrder = definition.navOrder
|
|
22
|
+
}
|
|
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())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return Object.keys(out).length ? out : null
|
|
30
|
+
}
|