@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.
@@ -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.39.6",
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.39.6",
45
+ "@ossy/design-system": "^1.40.0",
46
+ "@ossy/locale": "^1.40.0",
42
47
  "@ossy/pages": "^1.23.0",
43
- "@ossy/platform": "^1.38.6",
44
- "@ossy/router": "^1.39.6",
45
- "@ossy/router-react": "^1.39.6",
46
- "@ossy/sdk": "^1.39.6",
47
- "@ossy/sdk-react": "^1.39.6",
48
- "@ossy/themes": "^1.39.6",
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": "72d150cf20ff7ba25b328bc5b429b065c2840b2b"
87
+ "gitHead": "d9d31182be64a448d575da432af2830d909ad4b9"
83
88
  }
@@ -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.htmlLang || props.defaultLanguage || 'en'
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.title || props.documentTitle || ''),
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 components = await loadComponents(componentEntries)
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 }, components, Layout]) => {
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
+ }