@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.
- package/package.json +2 -2
- package/plugin/assistant/umbraco-package.json +1 -1
- package/plugin/audit-dashboard.js +369 -0
- package/plugin/branding/components-tree.js +1 -1
- package/plugin/branding/localization-en.js +1 -1
- package/plugin/bundles-carries.js +16 -0
- package/plugin/bundles-client.js +8 -2
- package/plugin/bundles-created-overview.js +96 -2
- package/plugin/bundles-installed.js +36 -7
- package/plugin/bundles-marketplace.js +41 -4
- package/plugin/changes-dashboard.js +38 -1
- package/plugin/localizations/umbraco-package.json +1 -1
- package/plugin/seo/umbraco-package.json +1 -1
- package/plugin/umbraco-package.json +18 -1
- package/src/bundle-package.ts +266 -0
- package/src/extensions.ts +42 -81
- package/src/index.ts +1 -0
|
@@ -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
|
|
11
|
-
*
|
|
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
|
-
|
|
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",
|
|
4
4
|
"id": "Crombolo.Core",
|
|
5
|
-
"version": "0.
|
|
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/
|
|
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 {
|
|
14
|
-
import {
|
|
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
|
-
/**
|
|
122
|
-
export function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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:
|
|
132
|
-
directory,
|
|
88
|
+
version: bundle.version,
|
|
89
|
+
directory: bundle.directory,
|
|
90
|
+
bundle,
|
|
133
91
|
manifest: {
|
|
134
|
-
name:
|
|
135
|
-
id:
|
|
136
|
-
version:
|
|
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) =>
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
},
|