@ossy/app 1.39.6 → 1.39.7

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/cli/build.task.js CHANGED
@@ -137,14 +137,8 @@ function generateResourceStub ({ stubAbs, sourceAbs }) {
137
137
  }
138
138
 
139
139
  // Components are re-exported as-is — the manifest plugin reads `metadata.id`
140
- // and the optional `slot` export from the bundled output. Components don't get
141
- // route stubs or hydration bootstrapping; they're loaded on-demand at runtime.
142
- // The `slot` export (when present) identifies the named shell slot this
143
- // component fills automatically — see PlatformShell for the slot registry.
144
- // We use `export *` for named exports rather than an explicit list because
145
- // `slot` is optional: some components don't export it, and a static named
146
- // re-export of a binding that doesn't exist causes a Rollup MISSING_EXPORT
147
- // error at build time.
140
+ // from the bundled output. Shell placement is app-controlled via `export const slots`
141
+ // in `*.layout.jsx`, not `export const slot` on components.
148
142
  function generateComponentStub ({ stubAbs, sourceAbs }) {
149
143
  const importPath = relImport(stubAbs, sourceAbs)
150
144
  return [
@@ -261,6 +255,15 @@ async function loadConfig (configPath) {
261
255
  return mod.default || {}
262
256
  }
263
257
 
258
+ function readAppPackageName (cwd) {
259
+ try {
260
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'))
261
+ return typeof pkgJson.name === 'string' && pkgJson.name.trim() ? pkgJson.name : '@ossy/app'
262
+ } catch {
263
+ return '@ossy/app'
264
+ }
265
+ }
266
+
264
267
  export async function build (cliArgs = []) {
265
268
  arg(
266
269
  {
@@ -317,6 +320,7 @@ export async function build (cliArgs = []) {
317
320
 
318
321
  const configPath = path.resolve(srcDir, 'config.js')
319
322
  const configValue = await loadConfig(configPath)
323
+ const appPackageName = readAppPackageName(cwd)
320
324
 
321
325
  const middlewareSrc = path.resolve(srcDir, 'middleware.js')
322
326
  if (fs.existsSync(middlewareSrc)) {
@@ -374,6 +378,7 @@ export async function build (cliArgs = []) {
374
378
  srcDir,
375
379
  staticOutDir,
376
380
  configValue,
381
+ appPackageName,
377
382
  manifestPath: path.join(buildPath, 'manifest.json'),
378
383
  }),
379
384
  ],
@@ -24,6 +24,9 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
24
24
  * The serializable config object (typically `src/config.js`'s default
25
25
  * export). Inlined under `manifest.config` so the platform server doesn't
26
26
  * need a runtime `import('./config.js')`.
27
+ * @property {string} appPackageName
28
+ * npm package name of the app being built (e.g. `@ossy/app-test`). Used as
29
+ * the `package` field on manifest entries that live in the app `src/` tree.
27
30
  * @property {string} manifestPath
28
31
  * Absolute path to write the manifest to (typically `build/manifest.json`).
29
32
  *
@@ -41,12 +44,6 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
41
44
  * @typedef {object} ComponentManifestEntry
42
45
  * @property {string} id Unique component id (from `metadata.id`).
43
46
  * @property {string} entry URL the platform serves the component bundle from.
44
- * @property {string} [slot] Named shell slot this component fills (from `export const slot`).
45
- *
46
- * @typedef {object} SlotManifestEntry
47
- * @property {string} slot Named shell slot (e.g. `'header'`, `'sidebar'`, `'resource:booking/service/form'`).
48
- * @property {string} entry URL the platform serves the component bundle from.
49
- * @property {string} [package] npm package name that contributed this slot (from installed packages).
50
47
  *
51
48
  * @typedef {object} AggregateManifestEntry
52
49
  * @property {string} id AggregateType string (e.g. `'User'`).
@@ -73,6 +70,8 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
73
70
  * @typedef {object} LayoutManifestEntry
74
71
  * @property {string} id Unique layout id (e.g. `'app-shell'`).
75
72
  * @property {string} entry URL the platform serves the layout bundle from.
73
+ * @property {Record<string, string>} [slots]
74
+ * App-controlled slot map (`slotName` → component `metadata.id`), from `export const slots` in `*.layout.jsx`.
76
75
  *
77
76
  * @typedef {object} E2eManifestEntry
78
77
  * @property {string} id Unique test id (e.g. `'authentication/sign-in'`).
@@ -83,7 +82,6 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
83
82
  * @typedef {object} Manifest
84
83
  * @property {ManifestEntry[]} entries Flat, ordered list of every routable thing.
85
84
  * @property {ComponentManifestEntry[]} components Discovered `*.component.*` entries, keyed by id.
86
- * @property {SlotManifestEntry[]} slots Components that declare `export const slot` — auto-injected into shell slots.
87
85
  * @property {object[]} resourceTemplates Each `*.resource.js` file's default-exported template object.
88
86
  * @property {AggregateManifestEntry[]} aggregates Discovered `*.aggregate.js` entries from installed packages.
89
87
  * @property {IntegrationManifestEntry[]} integrations Discovered `*.integration.js` entries.
@@ -91,7 +89,7 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
91
89
  * @property {EmailManifestEntry[]} emails Discovered `*.email.{jsx,tsx}` entries.
92
90
  * @property {ActionManifestEntry[]} actions Discovered `*.action.js` entries.
93
91
  * @property {E2eManifestEntry[]} e2es Discovered `*.e2e.js` entries from installed packages.
94
- * @property {LayoutManifestEntry[]} layouts Discovered `*.layout.{jsx,tsx}` entries.
92
+ * @property {LayoutManifestEntry[]} layouts Discovered `*.layout.{jsx,tsx}` entries (includes app `slots` map).
95
93
  * @property {object} config Inlined `src/config.js` default export.
96
94
  */
97
95
 
@@ -129,7 +127,11 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
129
127
  * @param {ManifestPluginOptions} options
130
128
  * @returns {import('rollup').Plugin}
131
129
  */
132
- export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configValue, manifestPath }) {
130
+ function entryPackage (entryInfo, appPackageName) {
131
+ return entryInfo.packageName || appPackageName
132
+ }
133
+
134
+ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configValue, appPackageName = '@ossy/app', manifestPath }) {
133
135
  return {
134
136
  name: 'ossy-manifest',
135
137
  async writeBundle (_, bundle) {
@@ -156,8 +158,6 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
156
158
  const seenStartupIds = new Set()
157
159
  const seenEmailIds = new Set()
158
160
  const seenActionIds = new Set()
159
- /** @type {SlotManifestEntry[]} */
160
- const slots = []
161
161
  /** @type {E2eManifestEntry[]} */
162
162
  const e2es = []
163
163
  const seenE2eIds = new Set()
@@ -213,7 +213,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
213
213
  this.error(`[@ossy/app][build] Duplicate integration id "${integrationId}"`)
214
214
  }
215
215
  seenIntegrationIds.add(integrationId)
216
- integrations.push({ id: integrationId, entry: url, credentials })
216
+ integrations.push({ id: integrationId, entry: url, credentials, package: entryPackage(entryInfo, appPackageName) })
217
217
  continue
218
218
  }
219
219
 
@@ -236,7 +236,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
236
236
  this.error(`[@ossy/app][build] Duplicate startup id "${startupId}"`)
237
237
  }
238
238
  seenStartupIds.add(startupId)
239
- startups.push({ id: startupId, entry: url })
239
+ startups.push({ id: startupId, entry: url, package: entryPackage(entryInfo, appPackageName) })
240
240
  continue
241
241
  }
242
242
 
@@ -260,7 +260,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
260
260
  this.error(`[@ossy/app][build] Duplicate email id "${emailId}"`)
261
261
  }
262
262
  seenEmailIds.add(emailId)
263
- emails.push({ id: emailId, entry: url })
263
+ emails.push({ id: emailId, entry: url, package: entryPackage(entryInfo, appPackageName) })
264
264
  continue
265
265
  }
266
266
 
@@ -284,7 +284,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
284
284
  }
285
285
  const access = mod.access ?? 'authenticated'
286
286
  seenActionIds.add(actionId)
287
- actions.push({ id: actionId, entry: url, access })
287
+ actions.push({ id: actionId, entry: url, access, package: entryPackage(entryInfo, appPackageName) })
288
288
  continue
289
289
  }
290
290
 
@@ -308,7 +308,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
308
308
  this.error(`[@ossy/app][build] Duplicate aggregate id "${aggregateId}"`)
309
309
  }
310
310
  seenAggregateIds.add(aggregateId)
311
- aggregates.push({ id: aggregateId, entry: url })
311
+ aggregates.push({ id: aggregateId, entry: url, package: entryPackage(entryInfo, appPackageName) })
312
312
  continue
313
313
  }
314
314
 
@@ -341,6 +341,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
341
341
  feature: typeof feature === 'string' ? feature : '',
342
342
  requires: Array.isArray(requires) ? requires : [],
343
343
  entry: url,
344
+ package: entryPackage(entryInfo, appPackageName),
344
345
  })
345
346
  continue
346
347
  }
@@ -366,7 +367,38 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
366
367
  this.error(`[@ossy/app][build] Duplicate layout id "${layoutId}"`)
367
368
  }
368
369
  seenLayoutIds.add(layoutId)
369
- layouts.push({ id: layoutId, entry: url })
370
+ /** @type {Record<string, string> | undefined} */
371
+ let layoutSlots
372
+ const rawSlots = mod.slots
373
+ if (rawSlots != null) {
374
+ if (typeof rawSlots !== 'object' || Array.isArray(rawSlots)) {
375
+ this.error(
376
+ `[@ossy/app][build] layout entry ${entryInfo.sourcePath} must export \`slots\` as a plain object (slot name → component id)`,
377
+ )
378
+ }
379
+ layoutSlots = {}
380
+ for (const [slotKey, componentId] of Object.entries(rawSlots)) {
381
+ if (typeof slotKey !== 'string' || typeof componentId !== 'string') {
382
+ this.error(
383
+ `[@ossy/app][build] layout slots in ${entryInfo.sourcePath} must map string slot names to string component ids`,
384
+ )
385
+ }
386
+ const sk = slotKey.trim()
387
+ const cid = componentId.trim()
388
+ if (!sk || !cid) {
389
+ this.error(
390
+ `[@ossy/app][build] layout slots in ${entryInfo.sourcePath} must use non-empty slot names and component ids`,
391
+ )
392
+ }
393
+ layoutSlots[sk] = cid
394
+ }
395
+ }
396
+ layouts.push({
397
+ id: layoutId,
398
+ entry: url,
399
+ package: entryPackage(entryInfo, appPackageName),
400
+ ...(layoutSlots && Object.keys(layoutSlots).length > 0 ? { slots: layoutSlots } : {}),
401
+ })
370
402
  continue
371
403
  }
372
404
 
@@ -390,7 +422,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
390
422
  this.error(`[@ossy/app][build] Duplicate resource template id "${template.id}"`)
391
423
  }
392
424
  seenResourceIds.add(template.id)
393
- resourceTemplates.push(template)
425
+ resourceTemplates.push({ ...template, package: entryPackage(entryInfo, appPackageName) })
394
426
  continue
395
427
  }
396
428
 
@@ -405,12 +437,13 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
405
437
  seenIds[entryInfo.kind].add(id)
406
438
 
407
439
  if (entryInfo.kind === 'component') {
408
- const slotName = typeof mod.slot === 'string' && mod.slot.trim() ? mod.slot.trim() : undefined
409
- components.push({ id, entry: url, ...(slotName ? { slot: slotName } : {}) })
410
- if (slotName) {
411
- const packageName = entryInfo.packageName
412
- slots.push({ slot: slotName, entry: url, ...(packageName ? { package: packageName } : {}) })
440
+ if (mod.slot != null) {
441
+ this.error(
442
+ `[@ossy/app][build] component ${entryInfo.sourcePath} must not export \`slot\`. ` +
443
+ 'Assign components to shell slots in `*.layout.jsx` via `export const slots`.',
444
+ )
413
445
  }
446
+ components.push({ id, entry: url, package: entryPackage(entryInfo, appPackageName) })
414
447
  continue
415
448
  }
416
449
 
@@ -422,6 +455,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
422
455
  path: pagePath,
423
456
  title: rawMeta.title,
424
457
  entry: url,
458
+ package: entryPackage(entryInfo, appPackageName),
425
459
  })
426
460
  } else if (entryInfo.kind === 'api') {
427
461
  // APIs have no sensible default route (unlike pages where we can
@@ -435,9 +469,9 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
435
469
  `Add a \`path\` to the metadata or default-export object (e.g. \`path: '/api/${id}'\`).`,
436
470
  )
437
471
  }
438
- entries.push({ type: 'api', id, path: rawMeta.path, entry: url })
472
+ entries.push({ type: 'api', id, path: rawMeta.path, entry: url, package: entryPackage(entryInfo, appPackageName) })
439
473
  } else {
440
- entries.push({ type: 'task', id, entry: url })
474
+ entries.push({ type: 'task', id, entry: url, package: entryPackage(entryInfo, appPackageName) })
441
475
  }
442
476
  }
443
477
 
@@ -449,7 +483,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
449
483
  }
450
484
 
451
485
  /** @type {Manifest} */
452
- const manifest = { entries, components, slots, resourceTemplates, aggregates, integrations, startups, emails, actions, e2es, layouts, config: configValue || {} }
486
+ const manifest = { entries, components, resourceTemplates, aggregates, integrations, startups, emails, actions, e2es, layouts, config: configValue || {} }
453
487
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
454
488
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
455
489
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.39.6",
3
+ "version": "1.39.7",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -9,8 +9,10 @@
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"
14
16
  },
15
17
  "bin": {
16
18
  "app": "./cli/index.js"
@@ -38,14 +40,14 @@
38
40
  "@babel/eslint-parser": "^7.15.8",
39
41
  "@babel/preset-react": "^7.26.3",
40
42
  "@babel/register": "^7.25.9",
41
- "@ossy/design-system": "^1.39.6",
43
+ "@ossy/design-system": "^1.39.7",
42
44
  "@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",
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",
49
51
  "@rollup/plugin-alias": "^6.0.0",
50
52
  "@rollup/plugin-babel": "^7.0.0",
51
53
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -79,5 +81,5 @@
79
81
  "README.md",
80
82
  "tsconfig.json"
81
83
  ],
82
- "gitHead": "72d150cf20ff7ba25b328bc5b429b065c2840b2b"
84
+ "gitHead": "f65df0cd6da864c314c4f424112e05e8e2dec0ff"
83
85
  }
@@ -1,5 +1,6 @@
1
1
  import { createElement } from 'react'
2
2
  import { App } from '../src/shell/App.jsx'
3
+ import { resolvePageSlots } from './resolve-shell-slots.js'
3
4
 
4
5
  /**
5
6
  * Dynamically imports each component bundle listed in `entries` and returns a
@@ -47,8 +48,9 @@ export async function loadLayout (layoutEntry) {
47
48
 
48
49
  function buildTree ({ Component, Layout, metadata, props }) {
49
50
  const lang = props.htmlLang || props.defaultLanguage || 'en'
50
- const pageEl = createElement(Component, props)
51
- const contentEl = Layout ? createElement(Layout, props, pageEl) : pageEl
51
+ const contentEl = Layout
52
+ ? createElement(Layout, props)
53
+ : createElement(Component, props)
52
54
  return createElement(
53
55
  'html',
54
56
  { lang },
@@ -96,8 +98,13 @@ export function createPageEntry (pageModule, options = {}) {
96
98
  import('node:stream'),
97
99
  ])
98
100
 
99
- const { Layout = null, componentEntries = [], ...pageProps } = props
100
- const components = await loadComponents(componentEntries)
101
+ const { Layout = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
102
+ const componentsById = await loadComponents(componentEntries)
103
+ const components = resolvePageSlots({
104
+ layoutSlots,
105
+ componentsById,
106
+ pageComponent: Component,
107
+ })
101
108
  const tree = buildTree({ Component, Layout, metadata, props: { ...pageProps, components } })
102
109
  const bootstrapUrl = toBootstrapUrl(entryUrl)
103
110
  const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
@@ -133,12 +140,17 @@ export function createPageEntry (pageModule, options = {}) {
133
140
  if (typeof document === 'undefined' || typeof window === 'undefined') return
134
141
  hydrated = true
135
142
  const props = window.__OSSY__ || {}
136
- const { layoutEntry = null, componentEntries = [], ...pageProps } = props
143
+ const { layoutEntry = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
137
144
  Promise.all([
138
145
  import('react-dom/client'),
139
146
  loadComponents(componentEntries),
140
147
  loadLayout(layoutEntry),
141
- ]).then(([{ hydrateRoot }, components, Layout]) => {
148
+ ]).then(([{ hydrateRoot }, componentsById, Layout]) => {
149
+ const components = resolvePageSlots({
150
+ layoutSlots,
151
+ componentsById,
152
+ pageComponent: Component,
153
+ })
142
154
  hydrateRoot(document, buildTree({ Component, Layout, metadata, props: { ...pageProps, components } }))
143
155
  })
144
156
  }
@@ -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,110 @@
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
+ return { packages }
110
+ }
package/src/shell/App.jsx CHANGED
@@ -26,8 +26,8 @@ export const App = ({ children, ..._appSettings }) => {
26
26
  <WorkspaceProvider sdk={sdk}>
27
27
  <Router {...appSettings} pages={appSettings.pages || []}>
28
28
  {children}
29
+ {appSettings.devMode && <ThemeEditor />}
29
30
  </Router>
30
- {appSettings.devMode && <ThemeEditor />}
31
31
  </WorkspaceProvider>
32
32
  </Theme>
33
33
  </ComponentSlotsProvider>
@@ -25,5 +25,7 @@ export function defaultAppSettings() {
25
25
  faviconHref: undefined,
26
26
  /** When true, main app sidebar is collapsed to icons (from server cookie / app-settings). */
27
27
  sidebarPrimaryCollapsed: false,
28
+ /** Grouped manifest entries by npm package — for dev tooling. */
29
+ manifestSummary: undefined,
28
30
  }
29
31
  }
@@ -0,0 +1,128 @@
1
+ import React, { useMemo } from 'react'
2
+ import { Button, Text, View } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { useApp } from './AppContext.js'
5
+ import { formatPagePaths, groupPagesByFeature } from './devPagesUtils.js'
6
+ import { packageNameToSlug } from '../manifest/build-manifest-summary.js'
7
+
8
+ const monoStyle = {
9
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
10
+ fontSize: '0.8125rem',
11
+ lineHeight: 1.4,
12
+ wordBreak: 'break-all',
13
+ }
14
+
15
+ const currentPageStyles = {
16
+ padding: 'var(--space-s)',
17
+ borderRadius: 'var(--space-xs)',
18
+ background: 'var(--surface-secondary, hsla(0, 0%, 0%, 0.04))',
19
+ }
20
+
21
+ export const DevPagesPanel = () => {
22
+ const app = useApp()
23
+ const router = useRouter()
24
+ const pages = app?.pages || []
25
+
26
+ const groups = useMemo(() => groupPagesByFeature(pages), [pages])
27
+
28
+ const currentPage = useMemo(
29
+ () => pages.find((p) => p.id === app?.pageId),
30
+ [pages, app?.pageId],
31
+ )
32
+
33
+ const hasPackageViewer = useMemo(
34
+ () => pages.some((p) => p.id === 'packages/detail'),
35
+ [pages],
36
+ )
37
+
38
+ const packageDetailHref = useMemo(() => {
39
+ if (!hasPackageViewer) return null
40
+ const pkg = currentPage?.package
41
+ if (!pkg) return null
42
+ return router.getHref({
43
+ id: 'packages/detail',
44
+ params: { packageSlug: packageNameToSlug(pkg) },
45
+ })
46
+ }, [currentPage?.package, hasPackageViewer, router])
47
+
48
+ const currentPathLabel = useMemo(() => {
49
+ if (currentPage?.path) return formatPagePaths(currentPage.path)
50
+ const url = app?.url || router.href
51
+ if (!url) return '—'
52
+ const pathname = url.split('?')[0].split('#')[0]
53
+ return pathname || '—'
54
+ }, [currentPage, router.href, app?.url])
55
+
56
+ const currentUrl = app?.url || router.href || '—'
57
+
58
+ return (
59
+ <View gap="m">
60
+ <Text variant="title-tertiary">Dev</Text>
61
+
62
+ <View gap="xs" style={currentPageStyles}>
63
+ <Text variant="title-tertiary" style={{ marginBottom: 'var(--space-xs)' }}>
64
+ Current page
65
+ </Text>
66
+ <Text style={monoStyle}>
67
+ <strong>id:</strong> {app?.pageId || '—'}
68
+ </Text>
69
+ <Text style={monoStyle}>
70
+ <strong>path:</strong> {currentPathLabel}
71
+ </Text>
72
+ <Text style={monoStyle}>
73
+ <strong>url:</strong> {currentUrl}
74
+ </Text>
75
+ {packageDetailHref && currentPage?.package && (
76
+ <Button
77
+ variant="link"
78
+ href={packageDetailHref}
79
+ style={{ alignSelf: 'flex-start', padding: 0, height: 'auto' }}
80
+ >
81
+ View package ({currentPage.package})
82
+ </Button>
83
+ )}
84
+ </View>
85
+
86
+ <View gap="s">
87
+ <Text variant="title-tertiary">
88
+ Pages ({pages.length})
89
+ </Text>
90
+
91
+ {groups.map(({ key, label, pages: groupPages }) => (
92
+ <View key={key} gap="xs">
93
+ <Text style={{ fontWeight: 700, fontSize: '0.875rem' }}>{label}</Text>
94
+ <View gap="xs">
95
+ {groupPages.map((page) => {
96
+ const href = router.getHref(page.id)
97
+ const selected = page.id === app?.pageId
98
+ return (
99
+ <Button
100
+ key={page.id}
101
+ href={href}
102
+ variant={selected ? 'tag-active' : 'link'}
103
+ aria-current={selected ? 'page' : undefined}
104
+ style={{
105
+ display: 'block',
106
+ textAlign: 'left',
107
+ width: '100%',
108
+ padding: 'var(--space-xs) var(--space-s)',
109
+ height: 'auto',
110
+ whiteSpace: 'normal',
111
+ }}
112
+ >
113
+ <View gap="2px" style={{ alignItems: 'flex-start' }}>
114
+ <Text style={{ ...monoStyle, fontWeight: 600 }}>{page.id}</Text>
115
+ <Text style={{ ...monoStyle, opacity: 0.85 }}>
116
+ {formatPagePaths(page.path)}
117
+ </Text>
118
+ </View>
119
+ </Button>
120
+ )
121
+ })}
122
+ </View>
123
+ </View>
124
+ ))}
125
+ </View>
126
+ </View>
127
+ )
128
+ }
@@ -1,47 +1,28 @@
1
1
  import React, { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useResource } from '@ossy/sdk-react'
3
3
  import { Overlay, Button, useTheme, View, Text } from '@ossy/design-system'
4
- import { useApp } from './AppContext.js'
4
+ import { DevPagesPanel } from './DevPagesPanel.jsx'
5
5
 
6
- const overlayStyles = {
7
- background: 'transparent',
8
- display: 'content',
9
- pointerEvents: 'none'
10
- }
11
-
12
- const blobStyles = {
6
+ const fabStyles = {
13
7
  boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
14
8
  borderRadius: '999px',
15
- position: 'absolute',
9
+ position: 'fixed',
16
10
  right: 'var(--space-m)',
17
11
  bottom: 'var(--space-m)',
18
12
  cursor: 'pointer',
19
13
  transition: 'transform .5s',
20
- pointerEvents: 'auto',
21
- padding: 'var(--space-m)'
22
- }
23
-
24
- const editorContainerStyles = {
25
- position: 'absolute',
26
- right: '0',
27
- top: '0',
28
- height: '100%',
29
- width: '100%',
30
- display: 'flex',
31
- justifyContent: 'flex-end',
32
- alignItems: 'stretch',
33
- padding: '8px 32px'
14
+ zIndex: 101,
15
+ padding: 'var(--space-m)',
34
16
  }
35
17
 
36
- const editorStyles = {
37
- width: '100%',
38
- maxWidth: '350px',
39
- height: '100%',
40
- boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
41
- padding: '16px 16px 76px 16px',
42
- overflowX: 'none',
18
+ const modalPanelStyles = {
19
+ width: 'min(720px, 80vw)',
20
+ minWidth: 'min(480px, 100%)',
21
+ maxHeight: '85vh',
43
22
  overflowY: 'auto',
44
- pointerEvents: 'auto'
23
+ overflowX: 'hidden',
24
+ margin: '0 auto',
25
+ boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
45
26
  }
46
27
 
47
28
  const ThemeSwitcher = () => {
@@ -59,7 +40,6 @@ const ThemeSwitcher = () => {
59
40
  }
60
41
 
61
42
  export const ThemeEditor = () => {
62
- const app = useApp()
63
43
  const { updateResourceContent } = useResource('PCX53TaGviq4_8KvK-VOp')
64
44
  const [isEditorOpen, setIsEditorOpen] = useState(false)
65
45
  const [viewCount, setViewCount] = useState(0)
@@ -68,8 +48,8 @@ export const ThemeEditor = () => {
68
48
  const temporarilyUpdateTheme = () => {}
69
49
 
70
50
  const toggleStyles = useMemo(() => !isEditorOpen
71
- ? blobStyles
72
- : { ...blobStyles, transform: 'rotate(-45deg)' }, [isEditorOpen])
51
+ ? fabStyles
52
+ : { ...fabStyles, transform: 'rotate(-45deg)' }, [isEditorOpen])
73
53
 
74
54
  const onToggle = useCallback(() => {
75
55
  setIsEditorOpen(!isEditorOpen)
@@ -103,44 +83,35 @@ export const ThemeEditor = () => {
103
83
  }, [isEditorOpen])
104
84
 
105
85
  return (
106
- <Overlay isVisible={true} style={overlayStyles}>
107
-
86
+ <>
108
87
  {
109
88
  isEditorOpen && (
110
- <div style={editorContainerStyles}>
111
- <View surface="primary" roundness="m" gap="m" style={editorStyles}>
112
- <View as="form" gap="s" onSubmit={onSaveTheme} onChange={onThemeChange}>
113
- {Object.entries(theme).map(([name, value]) => (
114
- <div style={{ marginBottom: '16px' }}>
115
- <label style={{ display: 'block', fontFamily: 'sans-serif', marginBottom: '4px', fontWeight: 'bold' }}>{name}</label>
116
- <input value={value} data-name={name} style={{ width: '100%', padding: '4px' }}/>
117
- </div>
118
- ))}
119
- <Button type="submit" variant="cta">
120
- Save
121
- </Button>
89
+ <Overlay isVisible={true} onClose={onToggle}>
90
+ <View layout="off-center" style={{ height: '100%', width: '100%' }}>
91
+ <View slot="content" style={modalPanelStyles}>
92
+ <View surface="primary" roundness="m" gap="m" inset="l">
93
+ <DevPagesPanel />
94
+ <View as="form" gap="s" onSubmit={onSaveTheme} onChange={onThemeChange}>
95
+ {Object.entries(theme).map(([name, value]) => (
96
+ <div style={{ marginBottom: '16px' }}>
97
+ <label style={{ display: 'block', fontFamily: 'sans-serif', marginBottom: '4px', fontWeight: 'bold' }}>{name}</label>
98
+ <input value={value} data-name={name} style={{ width: '100%', padding: '4px' }}/>
99
+ </div>
100
+ ))}
101
+ <Button type="submit" variant="cta">
102
+ Save
103
+ </Button>
104
+ </View>
105
+ <ThemeSwitcher/>
106
+ <Text>Views: {viewCount}</Text>
107
+ </View>
122
108
  </View>
123
- <ThemeSwitcher/>
124
- <Text>Views: {viewCount}</Text>
125
109
  </View>
126
- <View>
127
-
128
- <View>
129
- Pages: {app?.pages?.length || 0}
130
- </View>
131
-
132
- <View>
133
- {(app?.pages || []).map(page => (
134
- <Button>{page.id}</Button>
135
- ))}
136
- </View>
137
- </View>
138
- </div>
110
+ </Overlay>
139
111
  )
140
112
  }
141
113
 
142
114
  <Button variant="cta" prefix="math-plus" style={toggleStyles} onClick={onToggle} />
143
-
144
- </Overlay>
115
+ </>
145
116
  )
146
117
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @param {string} pageId
3
+ * @returns {string} Group key for sorting and labeling (e.g. `booking`, `app`).
4
+ */
5
+ export function getPageGroupKey (pageId) {
6
+ const slash = pageId.indexOf('/')
7
+ if (slash === -1) return 'app'
8
+ return pageId.slice(0, slash)
9
+ }
10
+
11
+ /**
12
+ * @param {string} groupKey
13
+ * @returns {string} Display label (e.g. `@ossy/booking`, `@ossy/app`).
14
+ */
15
+ export function getPageGroupLabel (groupKey) {
16
+ if (groupKey === 'app') return '@ossy/app'
17
+ return `@ossy/${groupKey}`
18
+ }
19
+
20
+ /**
21
+ * @param {string | Record<string, string> | undefined} path
22
+ * @returns {string}
23
+ */
24
+ export function formatPagePaths (path) {
25
+ if (!path) return '—'
26
+ if (typeof path === 'string') return path
27
+ return Object.entries(path)
28
+ .sort(([a], [b]) => a.localeCompare(b))
29
+ .map(([lang, p]) => `${lang}: ${p}`)
30
+ .join(' · ')
31
+ }
32
+
33
+ /**
34
+ * @param {Array<{ id: string, path?: string | Record<string, string> }>} pages
35
+ * @returns {Array<{ key: string, label: string, pages: typeof pages }>}
36
+ */
37
+ export function groupPagesByFeature (pages) {
38
+ const byKey = new Map()
39
+
40
+ for (const page of pages) {
41
+ const key = getPageGroupKey(page.id)
42
+ if (!byKey.has(key)) byKey.set(key, [])
43
+ byKey.get(key).push(page)
44
+ }
45
+
46
+ return Array.from(byKey.entries())
47
+ .sort(([a], [b]) => a.localeCompare(b))
48
+ .map(([key, groupPages]) => ({
49
+ key,
50
+ label: getPageGroupLabel(key),
51
+ pages: [...groupPages].sort((a, b) => a.id.localeCompare(b.id)),
52
+ }))
53
+ }