@ossy/app 1.39.7 → 1.40.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 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,25 +187,23 @@ 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
  }
198
200
 
199
- // Actions are named command handlers. The stub re-exports `id`, `access`, and
200
- // `run` so the manifest plugin can validate them and the platform server can
201
- // invoke them at runtime.
201
+ // Actions are intent (metadata only). Implementation lives in a matching `*.task.js`.
202
202
  function generateActionStub ({ stubAbs, sourceAbs }) {
203
203
  const importPath = relImport(stubAbs, sourceAbs)
204
204
  return [
205
205
  '// Generated by @ossy/app — do not edit',
206
- `export { id, access, run } from '${importPath}'`,
206
+ `export { metadata } from '${importPath}'`,
207
207
  '',
208
208
  ].join('\n')
209
209
  }
@@ -380,6 +380,7 @@ export async function build (cliArgs = []) {
380
380
  configValue,
381
381
  appPackageName,
382
382
  manifestPath: path.join(buildPath, 'manifest.json'),
383
+ projectRoot: cwd,
383
384
  }),
384
385
  ],
385
386
  })
@@ -398,4 +399,12 @@ export async function build (cliArgs = []) {
398
399
  fs.cpSync(publicSrc, publicOutDir, { recursive: true, force: true })
399
400
  }
400
401
 
402
+ mergeAndEmitTranslations({
403
+ srcDir,
404
+ projectRoot: cwd,
405
+ publicOutDir,
406
+ manifestPath: path.join(buildPath, 'manifest.json'),
407
+ configValue,
408
+ warn: (message) => console.warn(message),
409
+ })
401
410
  }
@@ -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) {
@@ -264,25 +268,19 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
264
268
  continue
265
269
  }
266
270
 
267
- // Actions are named, discoverable command handlers. The bundle exports
268
- // `id` (a unique slug), `run` (the handler function), and optionally
269
- // `access` ('public' | 'authenticated' | 'workspace', default 'authenticated').
271
+ // Actions are intent: `metadata` only (`{ id, access }`). Matching task
272
+ // with the same id holds `run` (see ADR 0003).
270
273
  if (entryInfo.kind === 'action') {
271
- const actionId = mod && mod.id
274
+ const actionId = mod?.metadata?.id
272
275
  if (typeof actionId !== 'string' || actionId.trim() === '') {
273
276
  this.error(
274
- `[@ossy/app][build] action entry ${entryInfo.sourcePath} must export a non-empty string "id"`,
275
- )
276
- }
277
- if (typeof mod.run !== 'function') {
278
- this.error(
279
- `[@ossy/app][build] action entry ${entryInfo.sourcePath} must export a "run" function`,
277
+ `[@ossy/app][build] action entry ${entryInfo.sourcePath} must export a non-empty string "metadata.id"`,
280
278
  )
281
279
  }
282
280
  if (seenActionIds.has(actionId)) {
283
281
  this.error(`[@ossy/app][build] Duplicate action id "${actionId}"`)
284
282
  }
285
- const access = mod.access ?? 'authenticated'
283
+ const access = mod.metadata?.access ?? 'authenticated'
286
284
  seenActionIds.add(actionId)
287
285
  actions.push({ id: actionId, entry: url, access, package: entryPackage(entryInfo, appPackageName) })
288
286
  continue
@@ -483,7 +481,24 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
483
481
  }
484
482
 
485
483
  /** @type {Manifest} */
486
- const manifest = { entries, components, resourceTemplates, aggregates, integrations, startups, emails, actions, e2es, layouts, config: configValue || {} }
484
+ const definitions = projectRoot
485
+ ? await discoverPackageDefinitions(projectRoot)
486
+ : {}
487
+
488
+ const manifest = {
489
+ entries,
490
+ components,
491
+ resourceTemplates,
492
+ aggregates,
493
+ integrations,
494
+ startups,
495
+ emails,
496
+ actions,
497
+ e2es,
498
+ layouts,
499
+ definitions,
500
+ config: configValue || {},
501
+ }
487
502
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
488
503
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
489
504
  },
@@ -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/cli/start.task.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import arg from 'arg'
3
3
  import { startServer } from '@ossy/platform/server'
4
+ import 'dotenv/config'
4
5
 
5
6
  export async function start (cliArgs = []) {
6
7
  const options = arg({
@@ -13,7 +14,7 @@ export async function start (cliArgs = []) {
13
14
  const cwd = options['--cwd'] ? path.resolve(options['--cwd']) : process.cwd()
14
15
  const buildDir = options['--build-dir'] || 'build'
15
16
  const port = options['--port']
16
-
17
+
17
18
  const { lifetime } = await startServer({ cwd, buildDir, port })
18
19
  await lifetime
19
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.39.7",
3
+ "version": "1.40.1",
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.1",
46
+ "@ossy/locale": "^1.40.1",
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.1",
49
+ "@ossy/router": "^1.40.1",
50
+ "@ossy/router-react": "^1.40.1",
51
+ "@ossy/sdk": "^1.40.1",
52
+ "@ossy/sdk-react": "^1.40.1",
53
+ "@ossy/themes": "^1.40.1",
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": "c0ba5d90749690634e4dc2705178ff8d89dd3070"
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
  }
@@ -1,5 +1,6 @@
1
1
  import React, { useState, useMemo, useCallback, useEffect } from 'react'
2
- import { useResource } from '@ossy/sdk-react'
2
+ import { updateResourceContent } from '@ossy/resources'
3
+ import { useSdk } from '@ossy/sdk-react'
3
4
  import { Overlay, Button, useTheme, View, Text } from '@ossy/design-system'
4
5
  import { DevPagesPanel } from './DevPagesPanel.jsx'
5
6
 
@@ -40,7 +41,7 @@ const ThemeSwitcher = () => {
40
41
  }
41
42
 
42
43
  export const ThemeEditor = () => {
43
- const { updateResourceContent } = useResource('PCX53TaGviq4_8KvK-VOp')
44
+ const sdk = useSdk()
44
45
  const [isEditorOpen, setIsEditorOpen] = useState(false)
45
46
  const [viewCount, setViewCount] = useState(0)
46
47
  // const [theme, temporarilyUpdateTheme] = useTheme()
@@ -72,7 +73,7 @@ export const ThemeEditor = () => {
72
73
 
73
74
  const onSaveTheme = event => {
74
75
  event.preventDefault()
75
- updateResourceContent(theme)
76
+ updateResourceContent(sdk, 'PCX53TaGviq4_8KvK-VOp', theme)
76
77
  }
77
78
 
78
79
  useEffect(() => {