@crombolo/backoffice-host 0.9.1 → 0.11.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.
@@ -7,11 +7,13 @@
7
7
  * an Umbraco package does not install into this CMS anyway.
8
8
  *
9
9
  * What it shows instead is the npm registry, searched for the keyword a crombolo
10
- * bundle publishes, and filtered to the ones that really declare backoffice
11
- * extensions. Installing runs `bun add` on the server.
10
+ * bundle publishes, and filtered to the ones whose manifest is a valid bundle.
11
+ * Installing runs `bun add` on the server, then adds the content the bundle
12
+ * carries unless the site or the person installing declines it.
12
13
  */
13
14
  import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
14
15
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
16
+ import { carriesTags } from './bundles-carries.js'
15
17
  import { installBundle, searchMarketplace } from './bundles-client.js'
16
18
 
17
19
  export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
@@ -23,6 +25,8 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
23
25
  _notice: { state: true },
24
26
  _keyword: { state: true },
25
27
  _url: { state: true },
28
+ _appliesContent: { state: true },
29
+ _declined: { state: true },
26
30
  }
27
31
 
28
32
  constructor() {
@@ -35,6 +39,8 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
35
39
  this._keyword = 'crombolo-bundle'
36
40
  this._url = ''
37
41
  this._query = ''
42
+ this._appliesContent = true
43
+ this._declined = new Set()
38
44
  }
39
45
 
40
46
  connectedCallback() {
@@ -50,6 +56,7 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
50
56
  this._items = result.items ?? []
51
57
  this._keyword = result.keyword ?? this._keyword
52
58
  this._url = result.url ?? ''
59
+ this._appliesContent = result.appliesContent !== false
53
60
  } catch (error) {
54
61
  this._error = error instanceof Error ? error.message : String(error)
55
62
  } finally {
@@ -66,13 +73,14 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
66
73
  this._busy = item.name
67
74
  this._notice = ''
68
75
  try {
69
- const outcome = await installBundle(item.name, item.version)
76
+ const outcome = await installBundle(item.name, item.version, !this._declined.has(item.name))
70
77
  // The dependency line is what there is to commit, so it is shown rather
71
78
  // than described: the install lives in this node's node_modules until it
72
79
  // is in the site's repository.
73
- this._notice = outcome.dependency
80
+ const installed = outcome.dependency
74
81
  ? `${outcome.message} — "${outcome.dependency.name}": "${outcome.dependency.range}"`
75
82
  : (outcome.message ?? '')
83
+ this._notice = outcome.content ? `${installed} ${outcome.content.message}` : installed
76
84
  if (outcome.ok) await this.#load()
77
85
  } catch (error) {
78
86
  this._notice = error instanceof Error ? error.message : String(error)
@@ -81,6 +89,25 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
81
89
  }
82
90
  }
83
91
 
92
+ #toggleContent(item, event) {
93
+ const declined = new Set(this._declined)
94
+ if (event.target.checked) declined.delete(item.name)
95
+ else declined.add(item.name)
96
+ this._declined = declined
97
+ }
98
+
99
+ #contentChoice(item) {
100
+ if (!item.carries?.content || item.installedVersion) return nothing
101
+ if (!this._appliesContent)
102
+ return html`<p class="meta">Its content is added separately on this site.</p>`
103
+ return html`
104
+ <uui-toggle
105
+ label="Add its content when installing"
106
+ .checked=${!this._declined.has(item.name)}
107
+ @change=${(event) => this.#toggleContent(item, event)}></uui-toggle>
108
+ `
109
+ }
110
+
84
111
  #empty() {
85
112
  return html`
86
113
  <uui-box headline="No bundles published yet">
@@ -120,6 +147,7 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
120
147
  <uui-box>
121
148
  <div slot="headline">${item.name} <small>${item.version}</small></div>
122
149
  <p>${item.description || 'No description.'}</p>
150
+ <p class="carries">${carriesTags(item.carries)}</p>
123
151
  <p class="meta">
124
152
  ${item.publisher ? html`<span>by ${item.publisher}</span>` : nothing}
125
153
  <a href=${item.links.npm} target="_blank" rel="noopener">npm</a>
@@ -129,6 +157,7 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
129
157
  : nothing
130
158
  }
131
159
  </p>
160
+ ${this.#contentChoice(item)}
132
161
  ${
133
162
  item.installedVersion
134
163
  ? html`<uui-tag look="positive">Installed ${item.installedVersion}</uui-tag>`
@@ -193,6 +222,14 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
193
222
  opacity: 0.7;
194
223
  font-weight: normal;
195
224
  }
225
+ .carries {
226
+ display: flex;
227
+ gap: var(--uui-size-space-2, 6px);
228
+ }
229
+ uui-toggle {
230
+ display: block;
231
+ margin-bottom: var(--uui-size-space-4, 12px);
232
+ }
196
233
  `
197
234
  }
198
235
 
@@ -6,6 +6,9 @@
6
6
  * Findings arrive from two sources — a pending upgrade and an arriving content
7
7
  * bundle — so they are grouped rather than pooled: the counts of one say nothing
8
8
  * about the other, and the actions that resolve them are different.
9
+ *
10
+ * Below them, each installed bundle that keeps data of its own lists the
11
+ * changes it has made to its storage here, and any still to make.
9
12
  */
10
13
  import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
11
14
  import { fetchReport } from './report-client.js'
@@ -57,7 +60,7 @@ export default class CromboloChangesDashboardElement extends LitElement {
57
60
  render() {
58
61
  if (this._error) return html`<uui-box headline="Changes"><p>${this._error}</p></uui-box>`
59
62
  if (!this._report) return html`<uui-box headline="Changes"><uui-loader></uui-loader></uui-box>`
60
- const { health, findings, counts } = this._report
63
+ const { health, findings, counts, bundles = [] } = this._report
61
64
  const shown = findings.filter((f) => this._showResolved || f.status === 'open')
62
65
  return html`
63
66
  <uui-box headline="Changes">
@@ -86,10 +89,44 @@ export default class CromboloChangesDashboardElement extends LitElement {
86
89
  ? html`<p class="empty">Nothing outstanding. <code>crombolo upgrade check</code> and <code>crombolo content check</code> write findings here.</p>`
87
90
  : this.#group(shown).map((group) => this.#renderGroup(group))
88
91
  }
92
+ ${bundles.map((bundle) => this.#renderBundle(bundle))}
89
93
  </uui-box>
90
94
  `
91
95
  }
92
96
 
97
+ #renderBundle(bundle) {
98
+ const steps = [
99
+ ...bundle.pending.map((step) => ({ ...step, done: false })),
100
+ ...bundle.applied.map((step) => ({ ...step, done: true })).reverse(),
101
+ ]
102
+ return html`
103
+ <h4>${bundle.name} <small>data storage</small></h4>
104
+ ${
105
+ bundle.pending.length > 0
106
+ ? html`<p><uui-tag color="warning">${bundle.pending.length} change(s) waiting for the next upgrade</uui-tag></p>`
107
+ : nothing
108
+ }
109
+ <uui-table>
110
+ <uui-table-head>
111
+ <uui-table-head-cell>Change</uui-table-head-cell>
112
+ <uui-table-head-cell>Release</uui-table-head-cell>
113
+ <uui-table-head-cell>State</uui-table-head-cell>
114
+ </uui-table-head>
115
+ ${steps.map(
116
+ (step) => html`<uui-table-row>
117
+ <uui-table-cell>${step.name}${step.kind === 'contract' ? html`<small>removes data</small>` : nothing}</uui-table-cell>
118
+ <uui-table-cell>${step.release ?? ''}</uui-table-cell>
119
+ <uui-table-cell>${
120
+ step.done
121
+ ? html`<uui-tag color="positive">done ${step.appliedAt?.slice(0, 10) ?? ''}</uui-tag>`
122
+ : html`<uui-tag color="warning">waiting</uui-tag>`
123
+ }</uui-table-cell>
124
+ </uui-table-row>`,
125
+ )}
126
+ </uui-table>
127
+ `
128
+ }
129
+
93
130
  #renderGroup(group) {
94
131
  return html`
95
132
  <h4>
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Localizations",
4
4
  "id": "Crombolo.Localizations",
5
- "version": "0.9.1",
5
+ "version": "0.11.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Search & sharing",
4
4
  "id": "Crombolo.Seo",
5
- "version": "0.9.1",
5
+ "version": "0.11.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo",
4
4
  "id": "Crombolo.Core",
5
- "version": "0.9.1",
5
+ "version": "0.11.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -79,6 +79,23 @@
79
79
  }
80
80
  ]
81
81
  },
82
+ {
83
+ "type": "dashboard",
84
+ "alias": "Crombolo.Dashboard.AuditLog",
85
+ "name": "Crombolo Audit Log Dashboard",
86
+ "element": "/crombolo-plugin/audit-dashboard.js",
87
+ "weight": 4,
88
+ "meta": {
89
+ "label": "Audit log",
90
+ "pathname": "audit-log"
91
+ },
92
+ "conditions": [
93
+ {
94
+ "alias": "Umb.Condition.SectionAlias",
95
+ "match": "Umb.Section.Settings"
96
+ }
97
+ ]
98
+ },
82
99
  {
83
100
  "type": "dashboard",
84
101
  "alias": "Crombolo.Dashboard.Welcome",
@@ -0,0 +1,266 @@
1
+ /**
2
+ * A bundle is an npm package whose `package.json` carries the bundle manifest in
3
+ * its `crombolo` field. `docs/bundles.md`.
4
+ *
5
+ * This is the one place that field is read and validated, so discovery, the
6
+ * marketplace, the server half and the installer cannot disagree about what a
7
+ * bundle is.
8
+ */
9
+ import { existsSync, readFileSync, statSync } from 'node:fs'
10
+ import { dirname, isAbsolute, join, normalize, sep } from 'node:path'
11
+ import type { PackageManifestImportmap } from './manifests.ts'
12
+
13
+ /** The package a bundle declares its peer range on. */
14
+ export const CORE_PACKAGE = '@crombolo/crombolo'
15
+
16
+ /**
17
+ * A bundle's id: its route segment, its log name, and the prefix of anything
18
+ * else it owns. Lowercase, no dots or slashes, so it cannot climb out of a
19
+ * namespace however it is spelled.
20
+ */
21
+ export const BUNDLE_ID = /^[a-z][a-z0-9-]{1,38}$/
22
+
23
+ /** The `content` payload: always `bundle.json` inside the named directory. */
24
+ export const PAYLOAD_MANIFEST = 'bundle.json'
25
+
26
+ /** An exported binding name: what a site imports to wire the server half. */
27
+ const EXPORT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/
28
+
29
+ /** The `crombolo` field, as a bundle declares it. */
30
+ export interface CromboloField {
31
+ id: string
32
+ name?: string
33
+ allowPublicAccess?: boolean
34
+ allowTelemetry?: boolean
35
+ /** The backoffice half: Umbraco extension manifests. */
36
+ extensions?: unknown[]
37
+ importmap?: PackageManifestImportmap
38
+ /** A directory inside the package holding a `bundle.json` payload. */
39
+ content?: string
40
+ /** The factory the package exports for the server half. Never imported by core. */
41
+ server?: string
42
+ /**
43
+ * What this release's table steps promise a running site, for a bundle that
44
+ * owns tables: `breaking` exactly when it ships a contract.
45
+ */
46
+ migrations?: 'compatible' | 'breaking'
47
+ }
48
+
49
+ /** A bundle as read from its package. */
50
+ export interface BundlePackage {
51
+ packageName: string
52
+ version: string
53
+ directory: string
54
+ id: string
55
+ name: string
56
+ /** `peerDependencies["@crombolo/crombolo"]`. */
57
+ peerRange: string
58
+ private: boolean
59
+ field: CromboloField
60
+ /** Absolute path of the payload directory, when the bundle carries content. */
61
+ contentDir: string | undefined
62
+ carries: { extensions: boolean; content: boolean; server: boolean }
63
+ }
64
+
65
+ export interface PackageJson {
66
+ name?: string
67
+ version?: string
68
+ private?: boolean
69
+ dependencies?: Record<string, string>
70
+ peerDependencies?: Record<string, string>
71
+ crombolo?: unknown
72
+ }
73
+
74
+ export function readPackageJson(file: string): PackageJson | undefined {
75
+ try {
76
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown
77
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
78
+ ? (parsed as PackageJson)
79
+ : undefined
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Whether a package's `crombolo` field declares a bundle at all.
87
+ *
88
+ * Core's own umbrella package carries a `crombolo` field too — its release
89
+ * declaration — so the core package is never a bundle, whatever it holds.
90
+ */
91
+ export function declaresBundle(json: PackageJson | undefined): boolean {
92
+ return Boolean(json?.crombolo) && json?.name !== CORE_PACKAGE
93
+ }
94
+
95
+ /** A relative path that stays inside the package. */
96
+ function insidePackage(directory: string, relative: string): string | undefined {
97
+ if (relative === '' || isAbsolute(relative) || relative.includes('\0')) return undefined
98
+ const root = directory.endsWith(sep) ? directory : `${directory}${sep}`
99
+ const target = normalize(join(directory, relative))
100
+ return target === normalize(directory) || target.startsWith(root) ? target : undefined
101
+ }
102
+
103
+ /**
104
+ * Every reason a package's manifest is not a bundle, reported together.
105
+ *
106
+ * `directory` is where the package lives, so `content` can be checked against
107
+ * what is really there; a packument read from the registry has none, and only
108
+ * the declaration is checked.
109
+ */
110
+ export function validateBundlePackage(json: PackageJson, directory?: string): string[] {
111
+ const problems: string[] = []
112
+ const field = json.crombolo
113
+ if (!field || typeof field !== 'object' || Array.isArray(field))
114
+ return ['its package.json has no "crombolo" field, so it is not a bundle']
115
+ const f = field as Record<string, unknown>
116
+
117
+ if (typeof f.id !== 'string' || !BUNDLE_ID.test(f.id))
118
+ problems.push(
119
+ `"crombolo.id" must be 2–39 lowercase letters, digits and hyphens starting with a letter${
120
+ typeof f.id === 'string' ? `, not "${f.id}"` : ''
121
+ }`,
122
+ )
123
+ if (f.name !== undefined && (typeof f.name !== 'string' || f.name.trim() === ''))
124
+ problems.push('"crombolo.name" must be a non-empty string')
125
+ if (typeof json.name !== 'string' || json.name === '')
126
+ problems.push('its package.json has no "name"')
127
+ if (typeof json.version !== 'string' || json.version === '')
128
+ problems.push('its package.json has no "version"')
129
+
130
+ if (f.extensions !== undefined && !Array.isArray(f.extensions))
131
+ problems.push('"crombolo.extensions" must be a list of extension manifests')
132
+ if (f.migrations !== undefined && f.migrations !== 'compatible' && f.migrations !== 'breaking')
133
+ problems.push('"crombolo.migrations" is "compatible" or "breaking"')
134
+ if (f.server !== undefined && (typeof f.server !== 'string' || !EXPORT_NAME.test(f.server)))
135
+ problems.push('"crombolo.server" must name the factory the package exports')
136
+ if (f.content !== undefined) {
137
+ if (typeof f.content !== 'string') problems.push('"crombolo.content" must be a directory')
138
+ else if (directory) {
139
+ const target = insidePackage(directory, f.content)
140
+ if (!target) problems.push(`"crombolo.content" (${f.content}) is outside the package`)
141
+ else if (!existsSync(join(target, PAYLOAD_MANIFEST)))
142
+ problems.push(`"crombolo.content" (${f.content}) holds no ${PAYLOAD_MANIFEST}`)
143
+ } else if (isAbsolute(f.content) || f.content.split(/[\\/]/).includes('..'))
144
+ problems.push(`"crombolo.content" (${f.content}) is outside the package`)
145
+ }
146
+
147
+ const hasExtensions = Array.isArray(f.extensions) && f.extensions.length > 0
148
+ if (!hasExtensions && f.content === undefined && f.server === undefined)
149
+ problems.push(
150
+ 'it declares no "extensions", "content" or "server", so it is a bundle that does nothing',
151
+ )
152
+
153
+ const peer = json.peerDependencies?.[CORE_PACKAGE]
154
+ if (typeof peer !== 'string' || peer.trim() === '')
155
+ problems.push(`its package.json declares no peerDependencies["${CORE_PACKAGE}"]`)
156
+ return problems
157
+ }
158
+
159
+ /**
160
+ * The package at a directory, read as a bundle.
161
+ *
162
+ * `undefined` with no problems means the package is not a bundle at all — an
163
+ * ordinary dependency. Problems mean it claims to be one and is malformed.
164
+ */
165
+ export function readBundlePackage(directory: string): {
166
+ bundle: BundlePackage | undefined
167
+ problems: string[]
168
+ } {
169
+ const json = readPackageJson(join(directory, 'package.json'))
170
+ if (!declaresBundle(json)) return { bundle: undefined, problems: [] }
171
+ const problems = validateBundlePackage(json as PackageJson, directory)
172
+ if (problems.length > 0) return { bundle: undefined, problems }
173
+ const pkg = json as Required<Pick<PackageJson, 'name' | 'version'>> & PackageJson
174
+ const field = pkg.crombolo as CromboloField
175
+ return {
176
+ bundle: {
177
+ packageName: pkg.name,
178
+ version: pkg.version,
179
+ directory,
180
+ id: field.id,
181
+ name: field.name ?? pkg.name,
182
+ peerRange: pkg.peerDependencies?.[CORE_PACKAGE] as string,
183
+ private: pkg.private === true,
184
+ field,
185
+ contentDir: field.content ? insidePackage(directory, field.content) : undefined,
186
+ carries: {
187
+ extensions: Array.isArray(field.extensions) && field.extensions.length > 0,
188
+ content: field.content !== undefined,
189
+ server: field.server !== undefined,
190
+ },
191
+ },
192
+ problems: [],
193
+ }
194
+ }
195
+
196
+ const COMPARATOR = /^(?:[~^]|[<>]=?|=)?v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*])){0,2}(?:-[0-9A-Za-z.-]+)?$/
197
+
198
+ /** A semver range, token by token: Bun's own matcher accepts nonsense and matches it. */
199
+ export function isSemverRange(range: string): boolean {
200
+ const tokens = range.trim().split(/\s+/)
201
+ return tokens.length > 0 && tokens.every((t) => t === '||' || t === '-' || COMPARATOR.test(t))
202
+ }
203
+
204
+ /** Whether a bundle's peer range admits a core version; a reason when it does not. */
205
+ export function peerRangeProblem(bundle: BundlePackage, coreVersion: string): string | undefined {
206
+ // A workspace link is resolved to this very tree, so it is this version by construction.
207
+ if (bundle.peerRange.startsWith('workspace:')) return undefined
208
+ const satisfied =
209
+ isSemverRange(bundle.peerRange) && Bun.semver.satisfies(coreVersion, bundle.peerRange)
210
+ return satisfied
211
+ ? undefined
212
+ : `${bundle.packageName} (${bundle.id}) is written for ${CORE_PACKAGE} ${bundle.peerRange}, and this site runs ${coreVersion}`
213
+ }
214
+
215
+ /**
216
+ * The directory a dependency resolves to, walking up as Node does, so a
217
+ * workspace root's hoisted `node_modules` is found from a site inside it.
218
+ */
219
+ export function resolvePackageDir(from: string, name: string): string | undefined {
220
+ // A name may not climb out of node_modules, whatever a package.json says.
221
+ if (name.includes('..') || name.startsWith('/') || name.startsWith('.')) return undefined
222
+ let dir = normalize(from)
223
+ for (;;) {
224
+ const candidate = join(dir, 'node_modules', name)
225
+ if (existsSync(join(candidate, 'package.json'))) return candidate
226
+ const parent = dirname(dir)
227
+ if (parent === dir) return undefined
228
+ dir = parent
229
+ }
230
+ }
231
+
232
+ export interface DiscoveredBundles {
233
+ bundles: BundlePackage[]
234
+ /** A declared dependency that claims to be a bundle and is not a valid one. */
235
+ problems: { packageName: string; problems: string[] }[]
236
+ }
237
+
238
+ /**
239
+ * Every declared dependency of the site that is a bundle.
240
+ *
241
+ * Only declared dependencies are read, never a walk of `node_modules`, so a
242
+ * transitive dependency cannot claim to be a bundle the site never asked for.
243
+ */
244
+ export function discoverBundles(siteDir: string): DiscoveredBundles {
245
+ const json = readPackageJson(join(siteDir, 'package.json'))
246
+ const found: DiscoveredBundles = { bundles: [], problems: [] }
247
+ if (!json?.dependencies) return found
248
+ for (const name of Object.keys(json.dependencies).sort()) {
249
+ const directory = resolvePackageDir(siteDir, name)
250
+ if (!directory) continue
251
+ const read = readBundlePackage(directory)
252
+ if (read.bundle) found.bundles.push(read.bundle)
253
+ else if (read.problems.length > 0)
254
+ found.problems.push({ packageName: name, problems: read.problems })
255
+ }
256
+ return found
257
+ }
258
+
259
+ /** Whether a path is a directory, for telling a package path from a package name. */
260
+ export function isDirectory(path: string): boolean {
261
+ try {
262
+ return statSync(path).isDirectory()
263
+ } catch {
264
+ return false
265
+ }
266
+ }
package/src/extensions.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Backoffice extensions as npm dependencies. `docs/17-bundles.md`.
2
+ * Backoffice extensions as npm dependencies. `docs/bundles.md`.
3
3
  *
4
4
  * A site's extensions are its dependencies: discovery reads the site's own
5
5
  * `package.json`, resolves each dependency through `node_modules`, and takes the
@@ -10,8 +10,13 @@
10
10
  * transitive dependency cannot put an extension into a backoffice that never
11
11
  * asked for it.
12
12
  */
13
- import { existsSync, readFileSync } from 'node:fs'
14
- import { dirname, join, normalize, sep } from 'node:path'
13
+ import { join, normalize, sep } from 'node:path'
14
+ import {
15
+ type BundlePackage,
16
+ type DiscoveredBundles,
17
+ discoverBundles,
18
+ readBundlePackage,
19
+ } from './bundle-package.ts'
15
20
  import type { PackageManifest, PackageManifestImportmap } from './manifests.ts'
16
21
 
17
22
  /** Where an installed bundle's assets are served from. */
@@ -20,26 +25,14 @@ export const EXTENSION_PATH = '/bundles'
20
25
  /** The npm keyword a bundle publishes so the marketplace can find it. */
21
26
  export const EXTENSION_KEYWORD = 'crombolo-bundle'
22
27
 
23
- /**
24
- * What a dependency declares to become a backoffice extension. The shape is
25
- * Umbraco's manifest minus the parts the server owns — the name and version
26
- * come from the package itself.
27
- */
28
- export interface CromboloField {
29
- id?: string
30
- name?: string
31
- allowPublicAccess?: boolean
32
- allowTelemetry?: boolean
33
- extensions?: unknown[]
34
- importmap?: PackageManifestImportmap
35
- }
36
-
28
+ /** A bundle's backoffice half, as the manifest endpoint serves it. */
37
29
  export interface InstalledExtension {
38
30
  /** The npm package name, which is also its segment in the asset path. */
39
31
  packageName: string
40
32
  version: string
41
33
  directory: string
42
34
  manifest: PackageManifest
35
+ bundle: BundlePackage
43
36
  }
44
37
 
45
38
  /**
@@ -48,39 +41,6 @@ export interface InstalledExtension {
48
41
  */
49
42
  const ASSET_KEYS = new Set(['element', 'api', 'js', 'loader'])
50
43
 
51
- interface PackageJson {
52
- name?: string
53
- version?: string
54
- dependencies?: Record<string, string>
55
- crombolo?: CromboloField
56
- }
57
-
58
- function readJson(file: string): PackageJson | undefined {
59
- try {
60
- const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown
61
- return parsed && typeof parsed === 'object' ? (parsed as PackageJson) : undefined
62
- } catch {
63
- return undefined
64
- }
65
- }
66
-
67
- /**
68
- * The directory a dependency resolves to, walking up as Node does, so a
69
- * workspace root's hoisted `node_modules` is found from a site inside it.
70
- */
71
- export function resolvePackageDir(from: string, name: string): string | undefined {
72
- // A name may not climb out of node_modules, whatever a package.json says.
73
- if (name.includes('..') || name.startsWith('/') || name.startsWith('.')) return undefined
74
- let dir = normalize(from)
75
- for (;;) {
76
- const candidate = join(dir, 'node_modules', name)
77
- if (existsSync(join(candidate, 'package.json'))) return candidate
78
- const parent = dirname(dir)
79
- if (parent === dir) return undefined
80
- dir = parent
81
- }
82
- }
83
-
84
44
  /** `dist/seo.js` inside `@acme/seo` becomes `/bundles/@acme/seo/dist/seo.js`. */
85
45
  const assetPath = (packageName: string, target: string): string =>
86
46
  target.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(target)
@@ -118,42 +78,39 @@ function rewriteImportmap(
118
78
  return Object.keys(scopes).length > 0 ? { imports, scopes } : { imports }
119
79
  }
120
80
 
121
- /** One dependency's manifest, or undefined when it declares no extensions. */
122
- export function readExtension(
123
- directory: string,
124
- packageName: string,
125
- ): InstalledExtension | undefined {
126
- const json = readJson(join(directory, 'package.json'))
127
- const field = json?.crombolo
128
- if (!field || !Array.isArray(field.extensions) || field.extensions.length === 0) return undefined
81
+ /** A bundle's backoffice half, or undefined when it declares no extensions. */
82
+ export function extensionOf(bundle: BundlePackage): InstalledExtension | undefined {
83
+ const field = bundle.field
84
+ if (!bundle.carries.extensions) return undefined
85
+ const packageName = bundle.packageName
129
86
  return {
130
87
  packageName,
131
- version: json?.version ?? '0.0.0',
132
- directory,
88
+ version: bundle.version,
89
+ directory: bundle.directory,
90
+ bundle,
133
91
  manifest: {
134
- name: field.name ?? packageName,
135
- id: field.id ?? packageName,
136
- version: json?.version ?? '0.0.0',
92
+ name: bundle.name,
93
+ id: bundle.id,
94
+ version: bundle.version,
137
95
  allowPublicAccess: field.allowPublicAccess ?? false,
138
96
  allowTelemetry: field.allowTelemetry ?? false,
139
- extensions: field.extensions.map((extension) => rewriteAssets(extension, packageName)),
97
+ extensions: (field.extensions ?? []).map((extension) =>
98
+ rewriteAssets(extension, packageName),
99
+ ),
140
100
  importmap: rewriteImportmap(field.importmap, packageName),
141
101
  },
142
102
  }
143
103
  }
144
104
 
145
- /** Every declared dependency of the site that is a backoffice extension. */
105
+ /** One dependency's backoffice half, or undefined when it is no bundle or declares no extensions. */
106
+ export function readExtension(directory: string): InstalledExtension | undefined {
107
+ const { bundle } = readBundlePackage(directory)
108
+ return bundle ? extensionOf(bundle) : undefined
109
+ }
110
+
111
+ /** Every declared dependency of the site that is a bundle with a backoffice half. */
146
112
  export function discoverExtensions(siteDir: string): InstalledExtension[] {
147
- const json = readJson(join(siteDir, 'package.json'))
148
- if (!json?.dependencies) return []
149
- const found: InstalledExtension[] = []
150
- for (const name of Object.keys(json.dependencies).sort()) {
151
- const directory = resolvePackageDir(siteDir, name)
152
- if (!directory) continue
153
- const extension = readExtension(directory, name)
154
- if (extension) found.push(extension)
155
- }
156
- return found
113
+ return discoverBundles(siteDir).bundles.flatMap((bundle) => extensionOf(bundle) ?? [])
157
114
  }
158
115
 
159
116
  /**
@@ -197,6 +154,8 @@ export function resolveExtensionFile(
197
154
 
198
155
  export interface ExtensionRegistry {
199
156
  list(): readonly InstalledExtension[]
157
+ /** Every bundle the site depends on, whatever it carries, and the ones that are malformed. */
158
+ bundles(): DiscoveredBundles
200
159
  /** Called after an install or an uninstall, so the next read sees it. */
201
160
  invalidate(): void
202
161
  }
@@ -213,14 +172,16 @@ export function createExtensionRegistry(
213
172
  siteDir: string,
214
173
  options: { cache?: boolean } = {},
215
174
  ): ExtensionRegistry {
216
- let cached: InstalledExtension[] | undefined
175
+ let cached: DiscoveredBundles | undefined
217
176
  const cache = options.cache ?? true
177
+ const discovered = () => {
178
+ if (!cache) return discoverBundles(siteDir)
179
+ cached ??= discoverBundles(siteDir)
180
+ return cached
181
+ }
218
182
  return {
219
- list() {
220
- if (!cache) return discoverExtensions(siteDir)
221
- cached ??= discoverExtensions(siteDir)
222
- return cached
223
- },
183
+ list: () => discovered().bundles.flatMap((bundle) => extensionOf(bundle) ?? []),
184
+ bundles: discovered,
224
185
  invalidate() {
225
186
  cached = undefined
226
187
  },