@ossy/app 1.39.7 → 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 CHANGED
@@ -113,22 +113,25 @@ Build output: `build/.ossy/tasks.generated.json` — a registry of `[{ type, mod
113
113
 
114
114
  ## Components (`*.component.jsx`)
115
115
 
116
- Create `*.component.jsx` files in `src/` (or in any package dependency) to register slot-injectable components. The build discovers them automatically and bundles them separately — they are loaded on every page and slotted in by the layout when `metadata.id` matches the active page id.
116
+ Register injectable UI via `*.component.jsx` in `src/` or feature packages. Components are bundled separately and resolved into slots at runtime.
117
117
 
118
- ```jsx
119
- // src/workspace-users.component.jsx
120
- export const metadata = {
121
- id: 'workspace/users', // rendered when pageId === 'workspace/users'
122
- }
118
+ **Shell chrome** — the app assigns placement in `*.layout.jsx`:
123
119
 
124
- export default function WorkspaceUsersContent() {
125
- return <div>Users content</div>
120
+ ```jsx
121
+ export const slots = {
122
+ 'shell:header': 'app-header',
123
+ 'shell:sidebar': 'app-sidebar',
126
124
  }
127
125
  ```
128
126
 
129
- In your layout, use `<Slot name={app?.pageId} fallback={children} />` from `@ossy/design-system` to render the matching component (or fall back to the page's own output).
127
+ **Resource / input UI** feature packages use `metadata.id` as the slot key:
128
+
129
+ ```jsx
130
+ export const metadata = { id: 'resource:booking/service/form' }
131
+ export default function ServiceForm() { … }
132
+ ```
130
133
 
131
- See [`docs/component-primitive.md`](./docs/component-primitive.md) for the full contract, flow diagram, and examples.
134
+ See [`docs/component-primitive.md`](./docs/component-primitive.md) and [design-system/docs/SLOTS.md](../design-system/docs/SLOTS.md).
132
135
 
133
136
  ## Port configuration
134
137
 
package/cli/build.task.js CHANGED
@@ -22,10 +22,12 @@ import getPlatformFiles, {
22
22
  ACTION_FILE_PATTERN,
23
23
  E2E_FILE_PATTERN,
24
24
  LAYOUT_FILE_PATTERN,
25
+ TRANSLATIONS_FILE_PATTERN,
25
26
  } from './get-platform-files.task.js'
26
27
  import { manifestPlugin } from './manifest-plugin.js'
28
+ import { mergeAndEmitTranslations } from './merge-translations.task.js'
27
29
 
28
- export { PAGE_FILE_PATTERN, API_FILE_PATTERN, TASK_FILE_PATTERN, RESOURCE_FILE_PATTERN, COMPONENT_FILE_PATTERN, AGGREGATE_FILE_PATTERN, INTEGRATION_FILE_PATTERN, STARTUP_FILE_PATTERN, EMAIL_FILE_PATTERN, ACTION_FILE_PATTERN, E2E_FILE_PATTERN, LAYOUT_FILE_PATTERN }
30
+ export { PAGE_FILE_PATTERN, API_FILE_PATTERN, TASK_FILE_PATTERN, RESOURCE_FILE_PATTERN, COMPONENT_FILE_PATTERN, AGGREGATE_FILE_PATTERN, INTEGRATION_FILE_PATTERN, STARTUP_FILE_PATTERN, EMAIL_FILE_PATTERN, ACTION_FILE_PATTERN, E2E_FILE_PATTERN, LAYOUT_FILE_PATTERN, TRANSLATIONS_FILE_PATTERN }
29
31
 
30
32
  // Generated entry stubs live under `build/.ossy/entries/`. Putting them
31
33
  // inside `build/` means they're cleaned automatically with every build,
@@ -185,13 +187,13 @@ function generateStartupStub ({ stubAbs, sourceAbs }) {
185
187
  ].join('\n')
186
188
  }
187
189
 
188
- // Emails are React components. The stub re-exports `id`, `subject`, and the
189
- // default component export so the manifest plugin can validate them.
190
+ // Emails are React components. The stub re-exports `id` and the default component.
191
+ // Optional `subject` may be resolved from translations at send time instead.
190
192
  function generateEmailStub ({ stubAbs, sourceAbs }) {
191
193
  const importPath = relImport(stubAbs, sourceAbs)
192
194
  return [
193
195
  '// Generated by @ossy/app — do not edit',
194
- `export { id, subject, default } from '${importPath}'`,
196
+ `export { id, default } from '${importPath}'`,
195
197
  '',
196
198
  ].join('\n')
197
199
  }
@@ -380,6 +382,7 @@ export async function build (cliArgs = []) {
380
382
  configValue,
381
383
  appPackageName,
382
384
  manifestPath: path.join(buildPath, 'manifest.json'),
385
+ projectRoot: cwd,
383
386
  }),
384
387
  ],
385
388
  })
@@ -398,4 +401,12 @@ export async function build (cliArgs = []) {
398
401
  fs.cpSync(publicSrc, publicOutDir, { recursive: true, force: true })
399
402
  }
400
403
 
404
+ mergeAndEmitTranslations({
405
+ srcDir,
406
+ projectRoot: cwd,
407
+ publicOutDir,
408
+ manifestPath: path.join(buildPath, 'manifest.json'),
409
+ configValue,
410
+ warn: (message) => console.warn(message),
411
+ })
401
412
  }
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import {
5
+ discoverFilesByPattern,
6
+ TRANSLATIONS_FILE_PATTERN,
7
+ } from './get-platform-files.task.js'
8
+ import {
9
+ isValidLocaleCode,
10
+ parseLocaleFromTranslationFilename,
11
+ } from '@ossy/locale'
12
+
13
+ /**
14
+ * @param {string} projectRoot
15
+ * @returns {string[]}
16
+ */
17
+ function collectNodeModulesDirs (projectRoot) {
18
+ const dirs = []
19
+ let current = projectRoot
20
+ while (true) {
21
+ const nm = path.join(current, 'node_modules')
22
+ if (fs.existsSync(nm) && fs.statSync(nm).isDirectory()) dirs.push(nm)
23
+ const parent = path.dirname(current)
24
+ if (parent === current) break
25
+ current = parent
26
+ }
27
+ return dirs
28
+ }
29
+
30
+ /**
31
+ * @typedef {import('@ossy/locale').TranslationSourceFile} TranslationSourceFile
32
+ */
33
+
34
+ /**
35
+ * Discover `*.translations.json` from installed packages and app `src/`.
36
+ *
37
+ * @param {{ srcDir: string, projectRoot: string }} options
38
+ * @returns {TranslationSourceFile[]}
39
+ */
40
+ export function discoverTranslationFiles ({ srcDir, projectRoot }) {
41
+ /** @type {TranslationSourceFile[]} */
42
+ const files = []
43
+ const seenPaths = new Set()
44
+
45
+ const nmDirs = collectNodeModulesDirs(projectRoot)
46
+ /** @type {Array<{ packageName: string, sourcePath: string }>} */
47
+ const packageFiles = []
48
+
49
+ const tryPackageDir = (pkgDir) => {
50
+ const real = fs.realpathSync(pkgDir)
51
+ const pkgJsonPath = path.join(real, 'package.json')
52
+ if (!fs.existsSync(pkgJsonPath)) return
53
+
54
+ let pkg
55
+ try {
56
+ pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
57
+ } catch {
58
+ return
59
+ }
60
+ if (!pkg.ossy?.src) return
61
+
62
+ const packageSrcDir = path.resolve(real, pkg.ossy.src)
63
+ const matches = discoverFilesByPattern(packageSrcDir, TRANSLATIONS_FILE_PATTERN)
64
+ for (const sourcePath of matches) {
65
+ const stat = fs.statSync(sourcePath)
66
+ if (!stat.isFile() || stat.size === 0) continue
67
+ packageFiles.push({ packageName: pkg.name, sourcePath })
68
+ }
69
+ }
70
+
71
+ for (const nmDir of nmDirs) {
72
+ for (const entry of fs.readdirSync(nmDir, { withFileTypes: true })) {
73
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
74
+ if (entry.name.startsWith('@')) {
75
+ const scopeDir = path.join(nmDir, entry.name)
76
+ if (!fs.existsSync(scopeDir)) continue
77
+ for (const scoped of fs.readdirSync(scopeDir, { withFileTypes: true })) {
78
+ if (scoped.isDirectory() || scoped.isSymbolicLink()) {
79
+ tryPackageDir(path.join(scopeDir, scoped.name))
80
+ }
81
+ }
82
+ } else {
83
+ tryPackageDir(path.join(nmDir, entry.name))
84
+ }
85
+ }
86
+ }
87
+
88
+ packageFiles.sort((a, b) => {
89
+ const byPackage = a.packageName.localeCompare(b.packageName)
90
+ if (byPackage !== 0) return byPackage
91
+ return a.sourcePath.localeCompare(b.sourcePath)
92
+ })
93
+
94
+ for (const { packageName, sourcePath } of packageFiles) {
95
+ if (seenPaths.has(sourcePath)) continue
96
+ seenPaths.add(sourcePath)
97
+ const locale = parseLocaleFromTranslationFilename(path.basename(sourcePath))
98
+ if (!locale) continue
99
+ files.push({
100
+ locale,
101
+ sourcePath,
102
+ tier: 'package',
103
+ packageName,
104
+ sortKey: `${packageName}:${sourcePath}`,
105
+ })
106
+ }
107
+
108
+ const appFiles = discoverFilesByPattern(srcDir, TRANSLATIONS_FILE_PATTERN).sort()
109
+ for (const sourcePath of appFiles) {
110
+ if (seenPaths.has(sourcePath)) continue
111
+ seenPaths.add(sourcePath)
112
+ const stat = fs.statSync(sourcePath)
113
+ if (!stat.isFile() || stat.size === 0) continue
114
+ const locale = parseLocaleFromTranslationFilename(path.basename(sourcePath))
115
+ if (!locale) continue
116
+ files.push({
117
+ locale,
118
+ sourcePath,
119
+ tier: 'app',
120
+ sortKey: sourcePath,
121
+ })
122
+ }
123
+
124
+ return files
125
+ }
126
+
127
+ /**
128
+ * @param {TranslationSourceFile[]} files
129
+ */
130
+ export function validateTranslationLocales (files) {
131
+ for (const file of files) {
132
+ if (!isValidLocaleCode(file.locale)) {
133
+ throw new Error(
134
+ `[@ossy/app][build] Invalid locale "${file.locale}" in ${file.sourcePath}. ` +
135
+ 'Expected ISO 639-1 code (e.g. en, sv).',
136
+ )
137
+ }
138
+ }
139
+ }
@@ -13,6 +13,7 @@ export const EMAIL_FILE_PATTERN = /\.email\.(jsx?|tsx?)$/
13
13
  export const ACTION_FILE_PATTERN = /\.action\.(mjs|cjs|js)$/
14
14
  export const E2E_FILE_PATTERN = /\.e2e\.(mjs|cjs|js)$/
15
15
  export const LAYOUT_FILE_PATTERN = /\.layout\.(jsx?|tsx?)$/
16
+ export const TRANSLATIONS_FILE_PATTERN = /\.translations\.json$/
16
17
 
17
18
  /**
18
19
  * @typedef {'page' | 'api' | 'task' | 'resource' | 'component' | 'aggregate' | 'integration' | 'startup' | 'email' | 'action' | 'e2e' | 'layout'} EntryKind
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
 
5
5
  import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.js'
6
+ import { discoverPackageDefinitions } from '../src/manifest/discover-package-definitions.js'
6
7
 
7
8
  /**
8
9
  * @typedef {import('./get-platform-files.task.js').PlatformEntry} PlatformEntry
@@ -29,6 +30,8 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
29
30
  * the `package` field on manifest entries that live in the app `src/` tree.
30
31
  * @property {string} manifestPath
31
32
  * Absolute path to write the manifest to (typically `build/manifest.json`).
33
+ * @property {string} projectRoot
34
+ * Absolute path to the consuming app root — used to discover package definitions.
32
35
  *
33
36
  * @typedef {'page' | 'api' | 'task'} ManifestEntryType
34
37
  *
@@ -91,6 +94,7 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
91
94
  * @property {E2eManifestEntry[]} e2es Discovered `*.e2e.js` entries from installed packages.
92
95
  * @property {LayoutManifestEntry[]} layouts Discovered `*.layout.{jsx,tsx}` entries (includes app `slots` map).
93
96
  * @property {object} config Inlined `src/config.js` default export.
97
+ * @property {Record<string, object>} [definitions] Package Definition metadata keyed by slug.
94
98
  */
95
99
 
96
100
  /**
@@ -131,7 +135,7 @@ function entryPackage (entryInfo, appPackageName) {
131
135
  return entryInfo.packageName || appPackageName
132
136
  }
133
137
 
134
- export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configValue, appPackageName = '@ossy/app', manifestPath }) {
138
+ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configValue, appPackageName = '@ossy/app', manifestPath, projectRoot }) {
135
139
  return {
136
140
  name: 'ossy-manifest',
137
141
  async writeBundle (_, bundle) {
@@ -483,7 +487,24 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
483
487
  }
484
488
 
485
489
  /** @type {Manifest} */
486
- const manifest = { entries, components, resourceTemplates, aggregates, integrations, startups, emails, actions, e2es, layouts, config: configValue || {} }
490
+ const definitions = projectRoot
491
+ ? await discoverPackageDefinitions(projectRoot)
492
+ : {}
493
+
494
+ const manifest = {
495
+ entries,
496
+ components,
497
+ resourceTemplates,
498
+ aggregates,
499
+ integrations,
500
+ startups,
501
+ emails,
502
+ actions,
503
+ e2es,
504
+ layouts,
505
+ definitions,
506
+ config: configValue || {},
507
+ }
487
508
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
488
509
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
489
510
  },
@@ -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.7",
3
+ "version": "1.40.0",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -12,7 +12,9 @@
12
12
  "./runtime/resolve-shell-slots": "./runtime/resolve-shell-slots.js",
13
13
  "./runtime/api-runtime": "./runtime/api-runtime.js",
14
14
  "./runtime/task-runtime": "./runtime/task-runtime.js",
15
- "./manifest/build-manifest-summary": "./src/manifest/build-manifest-summary.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"
16
18
  },
17
19
  "bin": {
18
20
  "app": "./cli/index.js"
@@ -40,14 +42,15 @@
40
42
  "@babel/eslint-parser": "^7.15.8",
41
43
  "@babel/preset-react": "^7.26.3",
42
44
  "@babel/register": "^7.25.9",
43
- "@ossy/design-system": "^1.39.7",
45
+ "@ossy/design-system": "^1.40.0",
46
+ "@ossy/locale": "^1.40.0",
44
47
  "@ossy/pages": "^1.23.0",
45
- "@ossy/platform": "^1.38.7",
46
- "@ossy/router": "^1.39.7",
47
- "@ossy/router-react": "^1.39.7",
48
- "@ossy/sdk": "^1.39.7",
49
- "@ossy/sdk-react": "^1.39.7",
50
- "@ossy/themes": "^1.39.7",
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",
51
54
  "@rollup/plugin-alias": "^6.0.0",
52
55
  "@rollup/plugin-babel": "^7.0.0",
53
56
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -81,5 +84,5 @@
81
84
  "README.md",
82
85
  "tsconfig.json"
83
86
  ],
84
- "gitHead": "f65df0cd6da864c314c4f424112e05e8e2dec0ff"
87
+ "gitHead": "d9d31182be64a448d575da432af2830d909ad4b9"
85
88
  }
@@ -1,4 +1,5 @@
1
1
  import { createElement } from 'react'
2
+ import { pageIdToDocumentTitleKey, resolveMessage } from '@ossy/locale'
2
3
  import { App } from '../src/shell/App.jsx'
3
4
  import { resolvePageSlots } from './resolve-shell-slots.js'
4
5
 
@@ -41,16 +42,44 @@ export async function loadLayout (layoutEntry) {
41
42
  try {
42
43
  const mod = await import(layoutEntry)
43
44
  return mod?.default ?? null
44
- } 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
+ }
45
49
  return null
46
50
  }
47
51
  }
48
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
+
49
79
  function buildTree ({ Component, Layout, metadata, props }) {
50
- const lang = props.htmlLang || props.defaultLanguage || 'en'
51
- const contentEl = Layout
52
- ? createElement(Layout, props)
53
- : createElement(Component, props)
80
+ const lang = props.language || props.defaultLanguage || 'en'
81
+ const pageEl = createElement(Component, props)
82
+ const contentEl = Layout ? createElement(Layout, props, pageEl) : pageEl
54
83
  return createElement(
55
84
  'html',
56
85
  { lang },
@@ -58,9 +87,9 @@ function buildTree ({ Component, Layout, metadata, props }) {
58
87
  'head',
59
88
  null,
60
89
  createElement('meta', { charSet: 'utf-8' }),
61
- createElement('title', null, metadata.title || props.documentTitle || ''),
90
+ createElement('title', null, resolveDocumentTitle(metadata, props)),
62
91
  ),
63
- createElement(App, props, contentEl),
92
+ createElement('body', null, createElement(App, props, contentEl)),
64
93
  )
65
94
  }
66
95
 
@@ -152,6 +181,10 @@ export function createPageEntry (pageModule, options = {}) {
152
181
  pageComponent: Component,
153
182
  })
154
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
+ }
155
188
  })
156
189
  }
157
190
 
@@ -106,5 +106,10 @@ export function buildManifestSummary (manifest) {
106
106
  }
107
107
  }
108
108
 
109
- return { packages }
109
+ const definitions =
110
+ manifest.definitions && typeof manifest.definitions === 'object' && !Array.isArray(manifest.definitions)
111
+ ? manifest.definitions
112
+ : {}
113
+
114
+ return { packages, definitions }
110
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
+ }
package/src/shell/App.jsx CHANGED
@@ -1,13 +1,13 @@
1
1
  import React from 'react'
2
2
  import { SDK } from '@ossy/sdk'
3
3
  import { WorkspaceProvider } from '@ossy/sdk-react'
4
- import { Theme, ComponentSlotsProvider } from '@ossy/design-system'
4
+ import { Theme, ComponentSlotsProvider, LocaleProvider } from '@ossy/design-system'
5
5
  import { ThemeEditor } from './ThemeEditor.jsx'
6
6
  import { defaultAppSettings } from './AppSettings.jsx'
7
7
  import { Router } from '@ossy/router-react'
8
8
  import { AppContext } from './AppContext.js'
9
9
 
10
- export const App = ({ children, ..._appSettings }) => {
10
+ export const App = ({ children, language, messages, fallbackMessages, ..._appSettings }) => {
11
11
  const appSettings = { ...defaultAppSettings(), ..._appSettings }
12
12
 
13
13
  // `components` holds resolved ComponentType values — not JSON-serializable,
@@ -20,17 +20,25 @@ export const App = ({ children, ..._appSettings }) => {
20
20
  })
21
21
 
22
22
  return (
23
- <AppContext.Provider value={contextSettings}>
24
- <ComponentSlotsProvider slots={components || {}}>
25
- <Theme theme={appSettings.theme} themes={appSettings.themes}>
26
- <WorkspaceProvider sdk={sdk}>
27
- <Router {...appSettings} pages={appSettings.pages || []}>
28
- {children}
29
- {appSettings.devMode && <ThemeEditor />}
30
- </Router>
31
- </WorkspaceProvider>
32
- </Theme>
33
- </ComponentSlotsProvider>
34
- </AppContext.Provider>
23
+ <LocaleProvider
24
+ language={language}
25
+ messages={messages}
26
+ fallbackMessages={fallbackMessages}
27
+ defaultLanguage={appSettings.defaultLanguage}
28
+ supportedLanguages={appSettings.supportedLanguages}
29
+ >
30
+ <AppContext.Provider value={contextSettings}>
31
+ <ComponentSlotsProvider slots={components || {}}>
32
+ <Theme theme={appSettings.theme} themes={appSettings.themes}>
33
+ <WorkspaceProvider sdk={sdk}>
34
+ <Router {...appSettings} pages={appSettings.pages || []}>
35
+ {children}
36
+ {appSettings.devMode && <ThemeEditor />}
37
+ </Router>
38
+ </WorkspaceProvider>
39
+ </Theme>
40
+ </ComponentSlotsProvider>
41
+ </AppContext.Provider>
42
+ </LocaleProvider>
35
43
  )
36
44
  }