@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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/assets/login.js +352 -0
  4. package/package.json +35 -4
  5. package/plugin/assistant/client.js +53 -0
  6. package/plugin/assistant/drawer.js +865 -0
  7. package/plugin/assistant/umbraco-package.json +16 -0
  8. package/plugin/branding/backdrop.svg +1 -0
  9. package/plugin/branding/branding.js +100 -0
  10. package/plugin/branding/bundle-builder-labels.js +63 -0
  11. package/plugin/branding/client-credentials.js +49 -0
  12. package/plugin/branding/components-tree.js +40 -0
  13. package/plugin/branding/content-type-labels.js +27 -0
  14. package/plugin/branding/favicon.svg +1 -0
  15. package/plugin/branding/help-menu.js +46 -0
  16. package/plugin/branding/icon-mark.js +15 -0
  17. package/plugin/branding/icon-registry.js +40 -0
  18. package/plugin/branding/localization-en.js +115 -0
  19. package/plugin/branding/log-viewer-menu.js +70 -0
  20. package/plugin/branding/logo-header.svg +1 -0
  21. package/plugin/branding/logo-surface.svg +1 -0
  22. package/plugin/branding/query-builder.js +28 -0
  23. package/plugin/branding/sign-in.js +80 -0
  24. package/plugin/branding/sysinfo.js +89 -0
  25. package/plugin/branding/theme.css +102 -0
  26. package/plugin/bundles-carries.js +16 -0
  27. package/plugin/bundles-client.js +40 -0
  28. package/plugin/bundles-created-overview.js +244 -0
  29. package/plugin/bundles-created.js +70 -0
  30. package/plugin/bundles-installed.js +176 -0
  31. package/plugin/bundles-marketplace.js +236 -0
  32. package/plugin/changes-dashboard.js +169 -0
  33. package/plugin/element-type-hint.js +58 -0
  34. package/plugin/form-picker.js +116 -0
  35. package/plugin/forms-client.js +52 -0
  36. package/plugin/forms-entries.js +244 -0
  37. package/plugin/forms-overview.js +380 -0
  38. package/plugin/localizations/ar.js +97 -0
  39. package/plugin/localizations/bs.js +86 -0
  40. package/plugin/localizations/cs.js +73 -0
  41. package/plugin/localizations/cy.js +92 -0
  42. package/plugin/localizations/da.js +101 -0
  43. package/plugin/localizations/de.js +79 -0
  44. package/plugin/localizations/en.js +71 -0
  45. package/plugin/localizations/es.js +52 -0
  46. package/plugin/localizations/fr.js +78 -0
  47. package/plugin/localizations/he.js +24 -0
  48. package/plugin/localizations/hr.js +89 -0
  49. package/plugin/localizations/it.js +86 -0
  50. package/plugin/localizations/ja.js +43 -0
  51. package/plugin/localizations/ko.js +24 -0
  52. package/plugin/localizations/nb.js +44 -0
  53. package/plugin/localizations/nl.js +85 -0
  54. package/plugin/localizations/pl.js +44 -0
  55. package/plugin/localizations/pt-br.js +25 -0
  56. package/plugin/localizations/pt.js +100 -0
  57. package/plugin/localizations/ro.js +9 -0
  58. package/plugin/localizations/ru.js +46 -0
  59. package/plugin/localizations/sv.js +46 -0
  60. package/plugin/localizations/tr.js +80 -0
  61. package/plugin/localizations/uk.js +46 -0
  62. package/plugin/localizations/umbraco-package.json +279 -0
  63. package/plugin/localizations/vi.js +104 -0
  64. package/plugin/localizations/zh-tw.js +38 -0
  65. package/plugin/localizations/zh.js +38 -0
  66. package/plugin/read-only-banner.js +48 -0
  67. package/plugin/report-client.js +13 -0
  68. package/plugin/seo/client.js +35 -0
  69. package/plugin/seo/dashboard.js +242 -0
  70. package/plugin/seo/document-view.js +161 -0
  71. package/plugin/seo/type-hint.js +45 -0
  72. package/plugin/seo/umbraco-package.json +61 -0
  73. package/plugin/settings-dashboard.js +123 -0
  74. package/plugin/tsx-editors.js +337 -0
  75. package/plugin/tsx-snippets.js +63 -0
  76. package/plugin/umbraco-package.json +298 -0
  77. package/plugin/welcome-dashboard.js +106 -0
  78. package/src/branding.ts +59 -0
  79. package/src/bundle-package.ts +266 -0
  80. package/src/extensions.ts +189 -0
  81. package/src/graphics.ts +87 -0
  82. package/src/index.ts +8 -0
  83. package/src/login-assets.ts +18 -0
  84. package/src/manifests.ts +154 -0
  85. package/src/paths.ts +117 -0
  86. package/src/shell.ts +139 -0
  87. package/src/static.ts +107 -0
  88. package/src/text-imports.d.ts +23 -0
  89. 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
+ }
@@ -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,8 @@
1
+ export * from './bundle-package.ts'
2
+ export * from './extensions.ts'
3
+ export * from './graphics.ts'
4
+ export * from './login-assets.ts'
5
+ export * from './manifests.ts'
6
+ export * from './paths.ts'
7
+ export * from './shell.ts'
8
+ export * from './static.ts'
@@ -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
+ }