@crombolo/backoffice-host 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/assets/login.js +352 -0
- package/package.json +35 -4
- package/plugin/assistant/client.js +53 -0
- package/plugin/assistant/drawer.js +865 -0
- package/plugin/assistant/umbraco-package.json +16 -0
- package/plugin/branding/backdrop.svg +1 -0
- package/plugin/branding/branding.js +100 -0
- package/plugin/branding/bundle-builder-labels.js +63 -0
- package/plugin/branding/client-credentials.js +49 -0
- package/plugin/branding/components-tree.js +40 -0
- package/plugin/branding/content-type-labels.js +27 -0
- package/plugin/branding/favicon.svg +1 -0
- package/plugin/branding/help-menu.js +46 -0
- package/plugin/branding/icon-mark.js +15 -0
- package/plugin/branding/icon-registry.js +40 -0
- package/plugin/branding/localization-en.js +115 -0
- package/plugin/branding/log-viewer-menu.js +70 -0
- package/plugin/branding/logo-header.svg +1 -0
- package/plugin/branding/logo-surface.svg +1 -0
- package/plugin/branding/query-builder.js +28 -0
- package/plugin/branding/sign-in.js +80 -0
- package/plugin/branding/sysinfo.js +89 -0
- package/plugin/branding/theme.css +102 -0
- package/plugin/bundles-carries.js +16 -0
- package/plugin/bundles-client.js +40 -0
- package/plugin/bundles-created-overview.js +244 -0
- package/plugin/bundles-created.js +70 -0
- package/plugin/bundles-installed.js +176 -0
- package/plugin/bundles-marketplace.js +236 -0
- package/plugin/changes-dashboard.js +169 -0
- package/plugin/element-type-hint.js +58 -0
- package/plugin/form-picker.js +116 -0
- package/plugin/forms-client.js +52 -0
- package/plugin/forms-entries.js +244 -0
- package/plugin/forms-overview.js +380 -0
- package/plugin/localizations/ar.js +97 -0
- package/plugin/localizations/bs.js +86 -0
- package/plugin/localizations/cs.js +73 -0
- package/plugin/localizations/cy.js +92 -0
- package/plugin/localizations/da.js +101 -0
- package/plugin/localizations/de.js +79 -0
- package/plugin/localizations/en.js +71 -0
- package/plugin/localizations/es.js +52 -0
- package/plugin/localizations/fr.js +78 -0
- package/plugin/localizations/he.js +24 -0
- package/plugin/localizations/hr.js +89 -0
- package/plugin/localizations/it.js +86 -0
- package/plugin/localizations/ja.js +43 -0
- package/plugin/localizations/ko.js +24 -0
- package/plugin/localizations/nb.js +44 -0
- package/plugin/localizations/nl.js +85 -0
- package/plugin/localizations/pl.js +44 -0
- package/plugin/localizations/pt-br.js +25 -0
- package/plugin/localizations/pt.js +100 -0
- package/plugin/localizations/ro.js +9 -0
- package/plugin/localizations/ru.js +46 -0
- package/plugin/localizations/sv.js +46 -0
- package/plugin/localizations/tr.js +80 -0
- package/plugin/localizations/uk.js +46 -0
- package/plugin/localizations/umbraco-package.json +279 -0
- package/plugin/localizations/vi.js +104 -0
- package/plugin/localizations/zh-tw.js +38 -0
- package/plugin/localizations/zh.js +38 -0
- package/plugin/read-only-banner.js +48 -0
- package/plugin/report-client.js +13 -0
- package/plugin/seo/client.js +35 -0
- package/plugin/seo/dashboard.js +242 -0
- package/plugin/seo/document-view.js +161 -0
- package/plugin/seo/type-hint.js +45 -0
- package/plugin/seo/umbraco-package.json +61 -0
- package/plugin/settings-dashboard.js +123 -0
- package/plugin/tsx-editors.js +337 -0
- package/plugin/tsx-snippets.js +63 -0
- package/plugin/umbraco-package.json +298 -0
- package/plugin/welcome-dashboard.js +106 -0
- package/src/branding.ts +59 -0
- package/src/bundle-package.ts +266 -0
- package/src/extensions.ts +189 -0
- package/src/graphics.ts +87 -0
- package/src/index.ts +8 -0
- package/src/login-assets.ts +18 -0
- package/src/manifests.ts +154 -0
- package/src/paths.ts +117 -0
- package/src/shell.ts +139 -0
- package/src/static.ts +107 -0
- package/src/text-imports.d.ts +23 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backoffice extensions as npm dependencies. `docs/bundles.md`.
|
|
3
|
+
*
|
|
4
|
+
* A site's extensions are its dependencies: discovery reads the site's own
|
|
5
|
+
* `package.json`, resolves each dependency through `node_modules`, and takes the
|
|
6
|
+
* `crombolo` field from the dependency's `package.json`. There is no directory
|
|
7
|
+
* convention to keep in step, and `bun add` is the install.
|
|
8
|
+
*
|
|
9
|
+
* Only declared dependencies are read, never a walk of `node_modules`, so a
|
|
10
|
+
* transitive dependency cannot put an extension into a backoffice that never
|
|
11
|
+
* asked for it.
|
|
12
|
+
*/
|
|
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'
|
|
20
|
+
import type { PackageManifest, PackageManifestImportmap } from './manifests.ts'
|
|
21
|
+
|
|
22
|
+
/** Where an installed bundle's assets are served from. */
|
|
23
|
+
export const EXTENSION_PATH = '/bundles'
|
|
24
|
+
|
|
25
|
+
/** The npm keyword a bundle publishes so the marketplace can find it. */
|
|
26
|
+
export const EXTENSION_KEYWORD = 'crombolo-bundle'
|
|
27
|
+
|
|
28
|
+
/** A bundle's backoffice half, as the manifest endpoint serves it. */
|
|
29
|
+
export interface InstalledExtension {
|
|
30
|
+
/** The npm package name, which is also its segment in the asset path. */
|
|
31
|
+
packageName: string
|
|
32
|
+
version: string
|
|
33
|
+
directory: string
|
|
34
|
+
manifest: PackageManifest
|
|
35
|
+
bundle: BundlePackage
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Asset references inside an extension. Only these carry a path; rewriting
|
|
40
|
+
* every string would corrupt labels and aliases that happen to look like one.
|
|
41
|
+
*/
|
|
42
|
+
const ASSET_KEYS = new Set(['element', 'api', 'js', 'loader'])
|
|
43
|
+
|
|
44
|
+
/** `dist/seo.js` inside `@acme/seo` becomes `/bundles/@acme/seo/dist/seo.js`. */
|
|
45
|
+
const assetPath = (packageName: string, target: string): string =>
|
|
46
|
+
target.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(target)
|
|
47
|
+
? target
|
|
48
|
+
: `${EXTENSION_PATH}/${packageName}/${target.replace(/^\.?\//, '')}`
|
|
49
|
+
|
|
50
|
+
function rewriteAssets(value: unknown, packageName: string): unknown {
|
|
51
|
+
if (Array.isArray(value)) return value.map((item) => rewriteAssets(item, packageName))
|
|
52
|
+
if (!value || typeof value !== 'object') return value
|
|
53
|
+
const out: Record<string, unknown> = {}
|
|
54
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
55
|
+
out[key] =
|
|
56
|
+
ASSET_KEYS.has(key) && typeof item === 'string'
|
|
57
|
+
? assetPath(packageName, item)
|
|
58
|
+
: rewriteAssets(item, packageName)
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rewriteImportmap(
|
|
64
|
+
importmap: PackageManifestImportmap | undefined,
|
|
65
|
+
packageName: string,
|
|
66
|
+
): PackageManifestImportmap | undefined {
|
|
67
|
+
if (!importmap) return undefined
|
|
68
|
+
const imports: Record<string, string> = {}
|
|
69
|
+
for (const [specifier, target] of Object.entries(importmap.imports ?? {}))
|
|
70
|
+
imports[specifier] = assetPath(packageName, target)
|
|
71
|
+
const scopes: Record<string, Record<string, string>> = {}
|
|
72
|
+
for (const [scope, scopeImports] of Object.entries(importmap.scopes ?? {})) {
|
|
73
|
+
const rewritten: Record<string, string> = {}
|
|
74
|
+
for (const [specifier, target] of Object.entries(scopeImports))
|
|
75
|
+
rewritten[specifier] = assetPath(packageName, target)
|
|
76
|
+
scopes[assetPath(packageName, scope)] = rewritten
|
|
77
|
+
}
|
|
78
|
+
return Object.keys(scopes).length > 0 ? { imports, scopes } : { imports }
|
|
79
|
+
}
|
|
80
|
+
|
|
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
|
|
86
|
+
return {
|
|
87
|
+
packageName,
|
|
88
|
+
version: bundle.version,
|
|
89
|
+
directory: bundle.directory,
|
|
90
|
+
bundle,
|
|
91
|
+
manifest: {
|
|
92
|
+
name: bundle.name,
|
|
93
|
+
id: bundle.id,
|
|
94
|
+
version: bundle.version,
|
|
95
|
+
allowPublicAccess: field.allowPublicAccess ?? false,
|
|
96
|
+
allowTelemetry: field.allowTelemetry ?? false,
|
|
97
|
+
extensions: (field.extensions ?? []).map((extension) =>
|
|
98
|
+
rewriteAssets(extension, packageName),
|
|
99
|
+
),
|
|
100
|
+
importmap: rewriteImportmap(field.importmap, packageName),
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
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. */
|
|
112
|
+
export function discoverExtensions(siteDir: string): InstalledExtension[] {
|
|
113
|
+
return discoverBundles(siteDir).bundles.flatMap((bundle) => extensionOf(bundle) ?? [])
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The package a `/packages/…` request names, and the file inside it.
|
|
118
|
+
*
|
|
119
|
+
* The name is matched against the site's own extensions rather than trusted, so
|
|
120
|
+
* this cannot be used to read an arbitrary file out of `node_modules`; a scoped
|
|
121
|
+
* name takes two segments, as npm writes it.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveExtensionFile(
|
|
124
|
+
extensions: readonly InstalledExtension[],
|
|
125
|
+
pathname: string,
|
|
126
|
+
): string | undefined {
|
|
127
|
+
const prefix = `${EXTENSION_PATH}/`
|
|
128
|
+
if (!pathname.startsWith(prefix)) return undefined
|
|
129
|
+
const rest = pathname.slice(prefix.length)
|
|
130
|
+
const segments = rest.split('/')
|
|
131
|
+
const take = rest.startsWith('@') || rest.toLowerCase().startsWith('%40') ? 2 : 1
|
|
132
|
+
if (segments.length <= take) return undefined
|
|
133
|
+
// Decoded before matching, so `%40acme/seo` finds the same package the
|
|
134
|
+
// manifest names. Safe because the name is only compared against the known
|
|
135
|
+
// extensions, never joined into a path — the file part is what gets joined.
|
|
136
|
+
let name: string
|
|
137
|
+
try {
|
|
138
|
+
name = decodeURIComponent(segments.slice(0, take).join('/'))
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
const relative = segments.slice(take).join('/')
|
|
143
|
+
const extension = extensions.find((candidate) => candidate.packageName === name)
|
|
144
|
+
if (!extension || relative.length === 0) return undefined
|
|
145
|
+
|
|
146
|
+
const decoded = decodeURIComponent(relative)
|
|
147
|
+
if (decoded.includes('\0')) return undefined
|
|
148
|
+
const root = extension.directory.endsWith(sep)
|
|
149
|
+
? extension.directory
|
|
150
|
+
: `${extension.directory}${sep}`
|
|
151
|
+
const target = normalize(join(extension.directory, decoded))
|
|
152
|
+
return target.startsWith(root) ? target : undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ExtensionRegistry {
|
|
156
|
+
list(): readonly InstalledExtension[]
|
|
157
|
+
/** Every bundle the site depends on, whatever it carries, and the ones that are malformed. */
|
|
158
|
+
bundles(): DiscoveredBundles
|
|
159
|
+
/** Called after an install or an uninstall, so the next read sees it. */
|
|
160
|
+
invalidate(): void
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Discovery, memoized.
|
|
165
|
+
*
|
|
166
|
+
* Reading a handful of `package.json` files on every manifest request would be
|
|
167
|
+
* wasteful, but the uncached behaviour is load-bearing in development: an author
|
|
168
|
+
* editing a local extension expects a reload to show it, which is why `cache`
|
|
169
|
+
* is off there rather than everywhere.
|
|
170
|
+
*/
|
|
171
|
+
export function createExtensionRegistry(
|
|
172
|
+
siteDir: string,
|
|
173
|
+
options: { cache?: boolean } = {},
|
|
174
|
+
): ExtensionRegistry {
|
|
175
|
+
let cached: DiscoveredBundles | undefined
|
|
176
|
+
const cache = options.cache ?? true
|
|
177
|
+
const discovered = () => {
|
|
178
|
+
if (!cache) return discoverBundles(siteDir)
|
|
179
|
+
cached ??= discoverBundles(siteDir)
|
|
180
|
+
return cached
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
list: () => discovered().bundles.flatMap((bundle) => extensionOf(bundle) ?? []),
|
|
184
|
+
bundles: discovered,
|
|
185
|
+
invalidate() {
|
|
186
|
+
cached = undefined
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/graphics.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login/back-office branding images. These live on Umbraco's
|
|
3
|
+
* BackOfficeGraphicsController, which is excluded from the OpenAPI document, so
|
|
4
|
+
* they cannot be routed through the contract-first router.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { MANAGEMENT_API_PATH } from '@crombolo/core'
|
|
9
|
+
import type { BackOfficePaths } from './paths.ts'
|
|
10
|
+
|
|
11
|
+
export const GRAPHICS_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office/graphics`
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Crombolo's own marks, as Umbraco's names them. The plain names are the ones
|
|
15
|
+
* the client asks for on the header bar, which the theme paints orange, so they
|
|
16
|
+
* get the cocoa mark; the `-alternative` ones sit on white surfaces and get the
|
|
17
|
+
* orange one.
|
|
18
|
+
*
|
|
19
|
+
* `login-background` fills the illustration panel of the client's own sign-in
|
|
20
|
+
* screen (`umb-auth-view`), which a session timing out puts in front of an
|
|
21
|
+
* editor. Our login page has no background of its own — it draws a card rather
|
|
22
|
+
* than Umbraco's split screen — so this is the only request for it, and leaving
|
|
23
|
+
* it unanswered left that panel an empty rounded rectangle.
|
|
24
|
+
*/
|
|
25
|
+
const GRAPHICS: Record<string, string> = {
|
|
26
|
+
logo: 'branding/logo-header.svg',
|
|
27
|
+
'logo-alternative': 'branding/logo-surface.svg',
|
|
28
|
+
'login-logo': 'branding/logo-header.svg',
|
|
29
|
+
'login-logo-alternative': 'branding/logo-surface.svg',
|
|
30
|
+
'login-background': 'branding/backdrop.svg',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Vendored asset paths served as crombolo's instead.
|
|
35
|
+
*
|
|
36
|
+
* These are Umbraco's own marks and artwork, which MIT does not license:
|
|
37
|
+
* MIT grants copyright permission and says nothing about trademarks. Each is
|
|
38
|
+
* requested by a vendored element that hard-codes its path, so the substitution
|
|
39
|
+
* happens here rather than by patching the client, and the originals are no
|
|
40
|
+
* longer committed under `backoffice-dist/upstream-static/`.
|
|
41
|
+
*
|
|
42
|
+
* `resolveStaticFile` consults this before falling back to `vendorDir`, on both
|
|
43
|
+
* the hashed and the plain prefix, so there is no path left that reaches an
|
|
44
|
+
* Umbraco mark.
|
|
45
|
+
*
|
|
46
|
+
* - `favicon.svg` — the Umbraco "U" roundel, requested by the shells
|
|
47
|
+
* - `umbraco-logo.svg` — the wordmark, requested by the header popover
|
|
48
|
+
* - `installer-illustration.svg` — the backdrop of `app-error.element.js`, which
|
|
49
|
+
* is a real runtime path, so this needs a replacement rather than a deletion
|
|
50
|
+
*/
|
|
51
|
+
export const BRANDED_ASSETS: Readonly<Record<string, string>> = {
|
|
52
|
+
'assets/favicon.svg': 'branding/favicon.svg',
|
|
53
|
+
'assets/umbraco-logo.svg': 'branding/logo-surface.svg',
|
|
54
|
+
'assets/installer-illustration.svg': 'branding/backdrop.svg',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The same substitution for a file the bundler names after its contents, where
|
|
59
|
+
* an exact path would be right until the next `vendor:backoffice` and then
|
|
60
|
+
* quietly wrong.
|
|
61
|
+
*
|
|
62
|
+
* `icon-umbraco.js` is the "U" roundel as a registry icon.
|
|
63
|
+
* `branding/icon-registry.js` keeps that name out of the icon picker, but the
|
|
64
|
+
* registry is addressed by name and any element may render one, so the glyph
|
|
65
|
+
* behind the name is ours as well. The bundled app — the one the shells load —
|
|
66
|
+
* asks for it as `chunks/icon-umbraco-<hash>.js`, while the unbundled tree has
|
|
67
|
+
* it under `packages/core/icon-registry/icons/`. Both are the one glyph.
|
|
68
|
+
*/
|
|
69
|
+
const BRANDED_PATTERNS: readonly (readonly [RegExp, string])[] = [
|
|
70
|
+
[/(?:^|\/)icon-umbraco(?:-[0-9a-z]+)?\.js$/, 'branding/icon-mark.js'],
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
/** The crombolo file to serve in place of a vendored one, by vendor-relative path. */
|
|
74
|
+
export function brandedAsset(relative: string): string | undefined {
|
|
75
|
+
return (
|
|
76
|
+
BRANDED_ASSETS[relative] ?? BRANDED_PATTERNS.find(([pattern]) => pattern.test(relative))?.[1]
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resolveGraphic(paths: BackOfficePaths, pathname: string): string | undefined {
|
|
81
|
+
const prefix = `${GRAPHICS_PREFIX}/`
|
|
82
|
+
if (!pathname.startsWith(prefix)) return undefined
|
|
83
|
+
const graphic = GRAPHICS[pathname.slice(prefix.length)]
|
|
84
|
+
if (!graphic) return undefined
|
|
85
|
+
const file = join(paths.pluginDir, graphic)
|
|
86
|
+
return existsSync(file) ? file : undefined
|
|
87
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login page's client module.
|
|
3
|
+
*
|
|
4
|
+
* It is a plain `.js` asset rather than compiled TypeScript because it imports
|
|
5
|
+
* bare `@umbraco-cms/backoffice/*` specifiers that only resolve in the browser,
|
|
6
|
+
* through the import map the page embeds.
|
|
7
|
+
*/
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export const LOGIN_ASSET_DIR = join(import.meta.dir, '../assets')
|
|
11
|
+
|
|
12
|
+
export function resolveLoginAsset(backOfficePath: string, pathname: string): string | undefined {
|
|
13
|
+
const prefix = `${backOfficePath}/login/`
|
|
14
|
+
if (!pathname.startsWith(prefix)) return undefined
|
|
15
|
+
const name = pathname.slice(prefix.length)
|
|
16
|
+
// Only the files we ship; no traversal, no directory listing.
|
|
17
|
+
return name === 'login.js' ? join(LOGIN_ASSET_DIR, 'login.js') : undefined
|
|
18
|
+
}
|