@ossy/app 1.37.1 → 1.37.2

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
@@ -21,10 +21,11 @@ import getPlatformFiles, {
21
21
  EMAIL_FILE_PATTERN,
22
22
  ACTION_FILE_PATTERN,
23
23
  E2E_FILE_PATTERN,
24
+ LAYOUT_FILE_PATTERN,
24
25
  } from './get-platform-files.task.js'
25
26
  import { manifestPlugin } from './manifest-plugin.js'
26
27
 
27
- 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 }
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 }
28
29
 
29
30
  // Generated entry stubs live under `build/.ossy/entries/`. Putting them
30
31
  // inside `build/` means they're cleaned automatically with every build,
@@ -74,7 +75,7 @@ function entryNameFromSource (sourcePath, srcDir, packageName) {
74
75
  }
75
76
 
76
77
  function stubFileNameFor (kind, sourcePath, srcDir, packageName) {
77
- const ext = (kind === 'page' || kind === 'component' || kind === 'email') ? '.entry.jsx' : '.entry.js'
78
+ const ext = (kind === 'page' || kind === 'component' || kind === 'email' || kind === 'layout') ? '.entry.jsx' : '.entry.js'
78
79
  return entryNameFromSource(sourcePath, srcDir, packageName) + ext
79
80
  }
80
81
 
@@ -218,6 +219,20 @@ function generateE2eStub ({ stubAbs, sourceAbs }) {
218
219
  ].join('\n')
219
220
  }
220
221
 
222
+ // Layouts are React components that wrap page renders. The stub re-exports
223
+ // everything (including the optional `applies` glob and required `id`) plus the
224
+ // default component so the manifest plugin can validate them and the server
225
+ // can inject them at render time.
226
+ function generateLayoutStub ({ stubAbs, sourceAbs }) {
227
+ const importPath = relImport(stubAbs, sourceAbs)
228
+ return [
229
+ '// Generated by @ossy/app — do not edit',
230
+ `export * from '${importPath}'`,
231
+ `export { default } from '${importPath}'`,
232
+ '',
233
+ ].join('\n')
234
+ }
235
+
221
236
  function stubFor (kind, args) {
222
237
  if (kind === 'page') return generatePageStub(args)
223
238
  if (kind === 'api') return generateApiStub(args)
@@ -229,6 +244,7 @@ function stubFor (kind, args) {
229
244
  if (kind === 'email') return generateEmailStub(args)
230
245
  if (kind === 'action') return generateActionStub(args)
231
246
  if (kind === 'e2e') return generateE2eStub(args)
247
+ if (kind === 'layout') return generateLayoutStub(args)
232
248
  return generateTaskStub(args)
233
249
  }
234
250
 
@@ -276,6 +292,7 @@ export async function build (cliArgs = []) {
276
292
  ...platformFiles.emails,
277
293
  ...platformFiles.actions,
278
294
  ...platformFiles.e2es,
295
+ ...platformFiles.layouts,
279
296
  ]
280
297
 
281
298
  const entriesByStub = new Map()
@@ -12,9 +12,10 @@ export const STARTUP_FILE_PATTERN = /\.startup\.(mjs|cjs|js)$/
12
12
  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
+ export const LAYOUT_FILE_PATTERN = /\.layout\.(jsx?|tsx?)$/
15
16
 
16
17
  /**
17
- * @typedef {'page' | 'api' | 'task' | 'resource' | 'component' | 'aggregate' | 'integration' | 'startup' | 'email' | 'action' | 'e2e'} EntryKind
18
+ * @typedef {'page' | 'api' | 'task' | 'resource' | 'component' | 'aggregate' | 'integration' | 'startup' | 'email' | 'action' | 'e2e' | 'layout'} EntryKind
18
19
  *
19
20
  * @typedef {object} PlatformEntry
20
21
  * @property {EntryKind} kind Which bucket this entry lives in.
@@ -34,6 +35,7 @@ export const E2E_FILE_PATTERN = /\.e2e\.(mjs|cjs|js)$/
34
35
  * @property {PlatformEntry[]} emails Discovered `*.email.{jsx,tsx,...}` entries.
35
36
  * @property {PlatformEntry[]} actions Discovered `*.action.{js,mjs,cjs}` entries.
36
37
  * @property {PlatformEntry[]} e2es Discovered `*.e2e.{js,mjs,cjs}` entries (installed packages only).
38
+ * @property {PlatformEntry[]} layouts Discovered `*.layout.{jsx,tsx,...}` entries.
37
39
  */
38
40
  export function discoverFilesByPattern (srcDir, filePattern) {
39
41
  const dir = path.resolve(srcDir)
@@ -63,6 +65,7 @@ function classifyFile (absPath) {
63
65
  if (EMAIL_FILE_PATTERN.test(base)) return 'email'
64
66
  if (ACTION_FILE_PATTERN.test(base)) return 'action'
65
67
  if (E2E_FILE_PATTERN.test(base)) return 'e2e'
68
+ if (LAYOUT_FILE_PATTERN.test(base)) return 'layout'
66
69
  return null
67
70
  }
68
71
 
@@ -138,6 +141,7 @@ export function discoverInstalledPackageEntries (projectRoot) {
138
141
  ...discoverFilesByPattern(packageSrcDir, EMAIL_FILE_PATTERN),
139
142
  ...discoverFilesByPattern(packageSrcDir, ACTION_FILE_PATTERN),
140
143
  ...discoverFilesByPattern(packageSrcDir, E2E_FILE_PATTERN),
144
+ ...discoverFilesByPattern(packageSrcDir, LAYOUT_FILE_PATTERN),
141
145
  ]
142
146
  for (const sourcePath of files) {
143
147
  const stat = fs.statSync(sourcePath)
@@ -181,8 +185,8 @@ export function discoverInstalledPackageEntries (projectRoot) {
181
185
  * @returns {Promise<PlatformFiles>}
182
186
  */
183
187
  export default async function getPlatformFiles (srcDir) {
184
- const out = { pages: [], apis: [], tasks: [], resources: [], components: [], aggregates: [], integrations: [], startups: [], emails: [], actions: [], e2es: [] }
185
- const bucketByKind = { page: 'pages', api: 'apis', task: 'tasks', resource: 'resources', component: 'components', aggregate: 'aggregates', integration: 'integrations', startup: 'startups', email: 'emails', action: 'actions', e2e: 'e2es' }
188
+ const out = { pages: [], apis: [], tasks: [], resources: [], components: [], aggregates: [], integrations: [], startups: [], emails: [], actions: [], e2es: [], layouts: [] }
189
+ const bucketByKind = { page: 'pages', api: 'apis', task: 'tasks', resource: 'resources', component: 'components', aggregate: 'aggregates', integration: 'integrations', startup: 'startups', email: 'emails', action: 'actions', e2e: 'e2es', layout: 'layouts' }
186
190
 
187
191
  // Aggregates and e2e tests are only discovered from installed packages — never
188
192
  // from the local `src/` — to avoid pulling in per-project duplicates of the
@@ -198,6 +202,7 @@ export default async function getPlatformFiles (srcDir) {
198
202
  ...discoverFilesByPattern(srcDir, STARTUP_FILE_PATTERN),
199
203
  ...discoverFilesByPattern(srcDir, EMAIL_FILE_PATTERN),
200
204
  ...discoverFilesByPattern(srcDir, ACTION_FILE_PATTERN),
205
+ ...discoverFilesByPattern(srcDir, LAYOUT_FILE_PATTERN),
201
206
  ]
202
207
  for (const sourcePath of localFiles) {
203
208
  const stat = fs.statSync(sourcePath)
@@ -64,6 +64,11 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
64
64
  * @property {string} entry URL the platform serves the action bundle from.
65
65
  * @property {string} access Access level: `'public'` | `'authenticated'` | `'workspace'`.
66
66
  *
67
+ * @typedef {object} LayoutManifestEntry
68
+ * @property {string} id Unique layout id (e.g. `'app-shell'`).
69
+ * @property {string} entry URL the platform serves the layout bundle from.
70
+ * @property {string} [applies] Optional glob pattern of page paths this layout applies to (default `'*'` = all pages).
71
+ *
67
72
  * @typedef {object} E2eManifestEntry
68
73
  * @property {string} id Unique test id (e.g. `'authentication/sign-in'`).
69
74
  * @property {string} feature Grouping label (e.g. `'authentication'`).
@@ -80,6 +85,7 @@ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.
80
85
  * @property {EmailManifestEntry[]} emails Discovered `*.email.{jsx,tsx}` entries.
81
86
  * @property {ActionManifestEntry[]} actions Discovered `*.action.js` entries.
82
87
  * @property {E2eManifestEntry[]} e2es Discovered `*.e2e.js` entries from installed packages.
88
+ * @property {LayoutManifestEntry[]} layouts Discovered `*.layout.{jsx,tsx}` entries.
83
89
  * @property {object} config Inlined `src/config.js` default export.
84
90
  */
85
91
 
@@ -147,7 +153,9 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
147
153
  /** @type {E2eManifestEntry[]} */
148
154
  const e2es = []
149
155
  const seenE2eIds = new Set()
150
-
156
+ /** @type {LayoutManifestEntry[]} */
157
+ const layouts = []
158
+ const seenLayoutIds = new Set()
151
159
  for (const fileName of Object.keys(bundle)) {
152
160
  const chunk = bundle[fileName]
153
161
  if (chunk.type !== 'chunk' || !chunk.isEntry) continue
@@ -329,6 +337,33 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
329
337
  continue
330
338
  }
331
339
 
340
+ // Layouts wrap page renders automatically. The bundle exports `id`
341
+ // (a unique slug), an optional `applies` glob, and a default export
342
+ // (the React component). They don't produce routable entries; they
343
+ // accumulate into a separate `layouts` array so the server can
344
+ // match and inject them at render time.
345
+ if (entryInfo.kind === 'layout') {
346
+ const layoutId = mod && mod.id
347
+ const component = mod && mod.default
348
+ if (typeof layoutId !== 'string' || layoutId.trim() === '') {
349
+ this.error(
350
+ `[@ossy/app][build] layout entry ${entryInfo.sourcePath} must export a non-empty string "id"`,
351
+ )
352
+ }
353
+ if (typeof component !== 'function') {
354
+ this.error(
355
+ `[@ossy/app][build] layout entry ${entryInfo.sourcePath} must default-export a React component function`,
356
+ )
357
+ }
358
+ if (seenLayoutIds.has(layoutId)) {
359
+ this.error(`[@ossy/app][build] Duplicate layout id "${layoutId}"`)
360
+ }
361
+ seenLayoutIds.add(layoutId)
362
+ const applies = typeof mod.applies === 'string' ? mod.applies : '*'
363
+ layouts.push({ id: layoutId, entry: url, applies })
364
+ continue
365
+ }
366
+
332
367
  // Resources are pure data — the default export *is* the template.
333
368
  // They don't use `metadata`, don't need a derived id, and don't
334
369
  // produce a routable manifest entry; they accumulate into a separate
@@ -390,7 +425,7 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
390
425
  }
391
426
 
392
427
  /** @type {Manifest} */
393
- const manifest = { entries, components, resourceTemplates, aggregates, integrations, startups, emails, actions, e2es, config: configValue || {} }
428
+ const manifest = { entries, components, resourceTemplates, aggregates, integrations, startups, emails, actions, e2es, layouts, config: configValue || {} }
394
429
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
395
430
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
396
431
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.37.1",
3
+ "version": "1.37.2",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -38,14 +38,14 @@
38
38
  "@babel/eslint-parser": "^7.15.8",
39
39
  "@babel/preset-react": "^7.26.3",
40
40
  "@babel/register": "^7.25.9",
41
- "@ossy/design-system": "^1.37.1",
41
+ "@ossy/design-system": "^1.37.2",
42
42
  "@ossy/pages": "^1.23.0",
43
- "@ossy/platform": "^1.36.1",
44
- "@ossy/router": "^1.37.1",
45
- "@ossy/router-react": "^1.37.1",
46
- "@ossy/sdk": "^1.37.1",
47
- "@ossy/sdk-react": "^1.37.1",
48
- "@ossy/themes": "^1.37.1",
43
+ "@ossy/platform": "^1.36.2",
44
+ "@ossy/router": "^1.37.2",
45
+ "@ossy/router-react": "^1.37.2",
46
+ "@ossy/sdk": "^1.37.2",
47
+ "@ossy/sdk-react": "^1.37.2",
48
+ "@ossy/themes": "^1.37.2",
49
49
  "@rollup/plugin-alias": "^6.0.0",
50
50
  "@rollup/plugin-babel": "^7.0.0",
51
51
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -79,5 +79,5 @@
79
79
  "README.md",
80
80
  "tsconfig.json"
81
81
  ],
82
- "gitHead": "d5b71d16ec56c819401c7a74f95d12b2347ca3c4"
82
+ "gitHead": "723d9d35aba3cd43700ecd6e72a7e7de96acff65"
83
83
  }
@@ -26,8 +26,10 @@ export async function loadComponents (entries = []) {
26
26
  return map
27
27
  }
28
28
 
29
- function buildTree ({ Component, metadata, props }) {
29
+ function buildTree ({ Component, Layout, metadata, props }) {
30
30
  const lang = props.htmlLang || props.defaultLanguage || 'en'
31
+ const pageEl = createElement(Component, props)
32
+ const contentEl = Layout ? createElement(Layout, props, pageEl) : pageEl
31
33
  return createElement(
32
34
  'html',
33
35
  { lang },
@@ -37,7 +39,7 @@ function buildTree ({ Component, metadata, props }) {
37
39
  createElement('meta', { charSet: 'utf-8' }),
38
40
  createElement('title', null, metadata.title || props.documentTitle || ''),
39
41
  ),
40
- createElement(App, props, createElement(Component, props)),
42
+ createElement(App, props, contentEl),
41
43
  )
42
44
  }
43
45
 
@@ -75,9 +77,9 @@ export function createPageEntry (pageModule, options = {}) {
75
77
  import('node:stream'),
76
78
  ])
77
79
 
78
- const { componentEntries = [], ...pageProps } = props
80
+ const { Layout = null, componentEntries = [], ...pageProps } = props
79
81
  const components = await loadComponents(componentEntries)
80
- const tree = buildTree({ Component, metadata, props: { ...pageProps, components } })
82
+ const tree = buildTree({ Component, Layout, metadata, props: { ...pageProps, components } })
81
83
  const bootstrapUrl = toBootstrapUrl(entryUrl)
82
84
  const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
83
85
  const bootstrapScriptContent =
@@ -109,12 +111,12 @@ export function createPageEntry (pageModule, options = {}) {
109
111
  if (typeof document === 'undefined' || typeof window === 'undefined') return
110
112
  hydrated = true
111
113
  const props = window.__OSSY__ || {}
112
- const { componentEntries = [], ...pageProps } = props
114
+ const { Layout = null, componentEntries = [], ...pageProps } = props
113
115
  Promise.all([
114
116
  import('react-dom/client'),
115
117
  loadComponents(componentEntries),
116
118
  ]).then(([{ hydrateRoot }, components]) => {
117
- hydrateRoot(document, buildTree({ Component, metadata, props: { ...pageProps, components } }))
119
+ hydrateRoot(document, buildTree({ Component, Layout, metadata, props: { ...pageProps, components } }))
118
120
  })
119
121
  }
120
122