@crombolo/backoffice-host 0.0.0-stage → 0.9.1
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-client.js +34 -0
- package/plugin/bundles-created-overview.js +150 -0
- package/plugin/bundles-created.js +70 -0
- package/plugin/bundles-installed.js +147 -0
- package/plugin/bundles-marketplace.js +199 -0
- package/plugin/changes-dashboard.js +132 -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/extensions.ts +228 -0
- package/src/graphics.ts +87 -0
- package/src/index.ts +7 -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
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
|
+
}
|
package/src/manifests.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package manifest discovery: the vendored client contributes the core
|
|
3
|
+
* manifest, the framework contributes its own, and each installed npm
|
|
4
|
+
* extension contributes one (`extensions.ts`).
|
|
5
|
+
*
|
|
6
|
+
* The merged importmap is what the shell embeds; without it the backoffice
|
|
7
|
+
* cannot resolve a single bare `@umbraco-cms/backoffice/*` specifier.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import type { InstalledExtension } from './extensions.ts'
|
|
12
|
+
import type { BackOfficePaths } from './paths.ts'
|
|
13
|
+
import { PLUGIN_PATH_PLACEHOLDER, VENDORED_ASSETS_PATH } from './paths.ts'
|
|
14
|
+
|
|
15
|
+
export const MANIFEST_FILE_NAME = 'umbraco-package.json'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The assistant's own manifest, which the server omits when the feature is not
|
|
19
|
+
* configured — so an unconfigured site serves no drawer and no button.
|
|
20
|
+
*/
|
|
21
|
+
export const ASSISTANT_MANIFEST_ID = 'Crombolo.Assistant'
|
|
22
|
+
|
|
23
|
+
export interface PackageManifestImportmap {
|
|
24
|
+
imports: Record<string, string>
|
|
25
|
+
scopes?: Record<string, Record<string, string>>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PackageManifest {
|
|
29
|
+
name: string
|
|
30
|
+
id?: string
|
|
31
|
+
version?: string
|
|
32
|
+
allowPublicAccess?: boolean
|
|
33
|
+
allowTelemetry?: boolean
|
|
34
|
+
allowCacheBusting?: boolean
|
|
35
|
+
extensions: unknown[]
|
|
36
|
+
importmap?: PackageManifestImportmap
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The wire shape of GET /manifest/manifest. */
|
|
40
|
+
export interface ManifestResponseModel {
|
|
41
|
+
name: string
|
|
42
|
+
id?: string | null
|
|
43
|
+
version?: string | null
|
|
44
|
+
cacheBuster?: string | null
|
|
45
|
+
extensions: unknown[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readManifest(file: string): PackageManifest | undefined {
|
|
49
|
+
const content = readFileSync(file, 'utf8')
|
|
50
|
+
if (content.trim().length === 0) return undefined
|
|
51
|
+
return JSON.parse(content) as PackageManifest
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Scans a directory for manifests: one in the root, or one per child directory. */
|
|
55
|
+
export function discoverManifests(dir: string): PackageManifest[] {
|
|
56
|
+
if (!existsSync(dir)) return []
|
|
57
|
+
const found: PackageManifest[] = []
|
|
58
|
+
const rootManifest = join(dir, MANIFEST_FILE_NAME)
|
|
59
|
+
if (existsSync(rootManifest)) {
|
|
60
|
+
const manifest = readManifest(rootManifest)
|
|
61
|
+
if (manifest) found.push(manifest)
|
|
62
|
+
}
|
|
63
|
+
for (const entry of readdirSync(dir)) {
|
|
64
|
+
const child = join(dir, entry)
|
|
65
|
+
if (!statSync(child).isDirectory()) continue
|
|
66
|
+
const file = join(child, MANIFEST_FILE_NAME)
|
|
67
|
+
if (!existsSync(file)) continue
|
|
68
|
+
const manifest = readManifest(file)
|
|
69
|
+
if (manifest) found.push(manifest)
|
|
70
|
+
}
|
|
71
|
+
return found
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ManifestSet {
|
|
75
|
+
all: PackageManifest[]
|
|
76
|
+
importmap: PackageManifestImportmap
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Collects the core manifest plus the installed extensions, and rewrites the
|
|
81
|
+
* importmap's virtual directory to the cache-busted assets path — exactly what
|
|
82
|
+
* Umbraco's HtmlHelperBackOfficeExtensions does with a string replace.
|
|
83
|
+
*
|
|
84
|
+
* `omit` drops a framework manifest by id, which is how an optional feature
|
|
85
|
+
* leaves no trace in the backoffice when it is not configured.
|
|
86
|
+
*/
|
|
87
|
+
export function collectManifests(
|
|
88
|
+
paths: BackOfficePaths,
|
|
89
|
+
extensions: readonly InstalledExtension[] = [],
|
|
90
|
+
options: { omit?: readonly string[] } = {},
|
|
91
|
+
): ManifestSet {
|
|
92
|
+
const omit = new Set(options.omit ?? [])
|
|
93
|
+
const all = [...discoverManifests(paths.vendorDir), ...frameworkManifests(paths)].filter(
|
|
94
|
+
(manifest) => !omit.has(manifest.id ?? ''),
|
|
95
|
+
)
|
|
96
|
+
all.push(...extensions.map((extension) => extension.manifest))
|
|
97
|
+
|
|
98
|
+
const imports: Record<string, string> = {}
|
|
99
|
+
const scopes: Record<string, Record<string, string>> = {}
|
|
100
|
+
for (const manifest of all) {
|
|
101
|
+
if (!manifest.importmap) continue
|
|
102
|
+
for (const [specifier, target] of Object.entries(manifest.importmap.imports)) {
|
|
103
|
+
imports[specifier] = rewriteAssetPath(target, paths)
|
|
104
|
+
}
|
|
105
|
+
for (const [scope, scopeImports] of Object.entries(manifest.importmap.scopes ?? {})) {
|
|
106
|
+
const rewritten: Record<string, string> = {}
|
|
107
|
+
for (const [specifier, target] of Object.entries(scopeImports)) {
|
|
108
|
+
rewritten[specifier] = rewriteAssetPath(target, paths)
|
|
109
|
+
}
|
|
110
|
+
scopes[rewriteAssetPath(scope, paths)] = rewritten
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const importmap: PackageManifestImportmap = { imports }
|
|
114
|
+
if (Object.keys(scopes).length > 0) importmap.scopes = scopes
|
|
115
|
+
return { all, importmap }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The framework's own package, its element paths rewritten to wherever the backoffice is mounted. */
|
|
119
|
+
function frameworkManifests(paths: BackOfficePaths): PackageManifest[] {
|
|
120
|
+
return discoverManifests(paths.pluginDir).map((manifest) => {
|
|
121
|
+
const text = JSON.stringify(manifest).replaceAll(
|
|
122
|
+
`"${PLUGIN_PATH_PLACEHOLDER}/`,
|
|
123
|
+
`"${paths.pluginPath}/`,
|
|
124
|
+
)
|
|
125
|
+
return JSON.parse(text) as PackageManifest
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rewriteAssetPath(target: string, paths: BackOfficePaths): string {
|
|
130
|
+
return target.startsWith(VENDORED_ASSETS_PATH)
|
|
131
|
+
? `${paths.assetsPath}${target.slice(VENDORED_ASSETS_PATH.length)}`
|
|
132
|
+
: target
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Public manifests are the ones the login screen may load before sign-in. */
|
|
136
|
+
export function toResponseModels(
|
|
137
|
+
manifests: readonly PackageManifest[],
|
|
138
|
+
cacheBuster: string,
|
|
139
|
+
visibility: 'all' | 'public' | 'private' = 'all',
|
|
140
|
+
): ManifestResponseModel[] {
|
|
141
|
+
return manifests
|
|
142
|
+
.filter((m) => {
|
|
143
|
+
if (visibility === 'all') return true
|
|
144
|
+
const isPublic = m.allowPublicAccess === true
|
|
145
|
+
return visibility === 'public' ? isPublic : !isPublic
|
|
146
|
+
})
|
|
147
|
+
.map((m) => ({
|
|
148
|
+
name: m.name,
|
|
149
|
+
id: m.id ?? null,
|
|
150
|
+
version: m.version ?? null,
|
|
151
|
+
cacheBuster: m.allowCacheBusting === false ? null : cacheBuster,
|
|
152
|
+
extensions: m.extensions ?? [],
|
|
153
|
+
}))
|
|
154
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { DEFAULT_BACKOFFICE_PATH as CORE_DEFAULT } from '@crombolo/core'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Path layout for the hosted backoffice.
|
|
8
|
+
*
|
|
9
|
+
* Umbraco serves assets from a cache-busted virtual directory,
|
|
10
|
+
* `/umbraco/backoffice/<hash>/…`, and rewrites the hash away in middleware. We
|
|
11
|
+
* reproduce that because the import map and the shell both embed the hashed
|
|
12
|
+
* path, and long-lived immutable caching depends on it.
|
|
13
|
+
*/
|
|
14
|
+
export interface BackOfficePaths {
|
|
15
|
+
/** e.g. /umbraco */
|
|
16
|
+
readonly backOfficePath: string
|
|
17
|
+
/** e.g. /umbraco/backoffice */
|
|
18
|
+
readonly virtualDirectory: string
|
|
19
|
+
/** e.g. /umbraco/backoffice/a1b2c3d4 */
|
|
20
|
+
readonly assetsPath: string
|
|
21
|
+
readonly cacheBustHash: string
|
|
22
|
+
/** Directory on disk holding the vendored client. */
|
|
23
|
+
readonly vendorDir: string
|
|
24
|
+
/** e.g. /crombolo/crombolo — where the framework's own backoffice package is served. */
|
|
25
|
+
readonly pluginPath: string
|
|
26
|
+
/** Directory on disk holding that package: a manifest and its modules. */
|
|
27
|
+
readonly pluginDir: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { DEFAULT_BACKOFFICE_PATH } from '@crombolo/core'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where the vendored client's own manifest says its assets live. The vendor
|
|
34
|
+
* script bakes this literal in, so it is the prefix to rewrite from however the
|
|
35
|
+
* backoffice is mounted — not `virtualDirectory`, which moves with it.
|
|
36
|
+
*/
|
|
37
|
+
export const VENDORED_ASSETS_PATH = '/umbraco/backoffice'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The stand-in our own manifests use for their element paths, which
|
|
41
|
+
* `collectManifests` rewrites to `pluginPath`. Nothing is ever served from it:
|
|
42
|
+
* the real path moves with wherever the backoffice is mounted, and a manifest on
|
|
43
|
+
* disk cannot know that, so it names this instead.
|
|
44
|
+
*/
|
|
45
|
+
export const PLUGIN_PATH_PLACEHOLDER = '/crombolo-plugin'
|
|
46
|
+
|
|
47
|
+
export interface BackOfficePathOptions {
|
|
48
|
+
backOfficePath?: string
|
|
49
|
+
vendorDir?: string
|
|
50
|
+
pluginDir?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The framework's backoffice package: the Changes dashboard and the read-only banner. */
|
|
54
|
+
export function defaultPluginDir(): string {
|
|
55
|
+
return join(import.meta.dir, '../plugin')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Derived from the vendored VERSION so a re-vendor invalidates browser caches. */
|
|
59
|
+
export function cacheBustHash(vendorDir: string): string {
|
|
60
|
+
const versionFile = join(vendorDir, 'VERSION')
|
|
61
|
+
const seed = existsSync(versionFile) ? readFileSync(versionFile, 'utf8').trim() : 'unvendored'
|
|
62
|
+
return createHash('sha256').update(seed).digest('hex').slice(0, 16)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The built client ships as its own package, `@crombolo/backoffice-dist`, so it
|
|
67
|
+
* is resolved like any dependency. The fallback is the monorepo layout, for a
|
|
68
|
+
* checkout where the workspace has not been installed yet.
|
|
69
|
+
*/
|
|
70
|
+
export function defaultVendorDir(): string {
|
|
71
|
+
try {
|
|
72
|
+
return join(
|
|
73
|
+
dirname(Bun.resolveSync('@crombolo/backoffice-dist/package.json', import.meta.dir)),
|
|
74
|
+
'dist',
|
|
75
|
+
)
|
|
76
|
+
} catch {
|
|
77
|
+
return join(import.meta.dir, '../../backoffice-dist/dist')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createBackOfficePaths(options: BackOfficePathOptions = {}): BackOfficePaths {
|
|
82
|
+
const backOfficePath = (options.backOfficePath ?? CORE_DEFAULT).replace(/\/+$/, '')
|
|
83
|
+
const vendorDir = options.vendorDir ?? defaultVendorDir()
|
|
84
|
+
const virtualDirectory = `${backOfficePath}/backoffice`
|
|
85
|
+
const hash = cacheBustHash(vendorDir)
|
|
86
|
+
return {
|
|
87
|
+
backOfficePath,
|
|
88
|
+
virtualDirectory,
|
|
89
|
+
assetsPath: `${virtualDirectory}/${hash}`,
|
|
90
|
+
cacheBustHash: hash,
|
|
91
|
+
vendorDir,
|
|
92
|
+
pluginPath: `${backOfficePath}/crombolo`,
|
|
93
|
+
pluginDir: options.pluginDir ?? defaultPluginDir(),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Client-side routes the SPA owns. Umbraco constrains its catch-all to these,
|
|
99
|
+
* so anything else under /umbraco is a genuine 404 rather than the shell.
|
|
100
|
+
*/
|
|
101
|
+
export const SPA_ROUTES = [
|
|
102
|
+
'section',
|
|
103
|
+
'preview',
|
|
104
|
+
'upgrade',
|
|
105
|
+
'install',
|
|
106
|
+
'oauth_complete',
|
|
107
|
+
'logout',
|
|
108
|
+
'error',
|
|
109
|
+
] as const
|
|
110
|
+
|
|
111
|
+
export function isSpaRoute(backOfficePath: string, pathname: string): boolean {
|
|
112
|
+
if (pathname === backOfficePath || pathname === `${backOfficePath}/`) return true
|
|
113
|
+
if (!pathname.startsWith(`${backOfficePath}/`)) return false
|
|
114
|
+
const rest = pathname.slice(backOfficePath.length + 1)
|
|
115
|
+
const first = rest.split('/')[0] ?? ''
|
|
116
|
+
return SPA_ROUTES.includes(first as (typeof SPA_ROUTES)[number])
|
|
117
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server-rendered HTML shells. This is the server's entire HTML
|
|
3
|
+
* responsibility: everything else is the SPA.
|
|
4
|
+
*
|
|
5
|
+
* Ported from Umbraco.Cms.StaticAssets/umbraco/UmbracoBackOffice/Index.cshtml
|
|
6
|
+
* and .../UmbracoLogin/Index.cshtml.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MANAGEMENT_API_PATH } from '@crombolo/core'
|
|
10
|
+
import type { PackageManifestImportmap } from './manifests.ts'
|
|
11
|
+
import type { BackOfficePaths } from './paths.ts'
|
|
12
|
+
|
|
13
|
+
export interface ShellSettings {
|
|
14
|
+
/** GlobalSettings.DefaultUILanguage — the initial UI language. */
|
|
15
|
+
defaultUiLanguage: string
|
|
16
|
+
/** SecuritySettings.KeepUserLoggedIn. */
|
|
17
|
+
keepUserLoggedIn: boolean
|
|
18
|
+
title: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_SHELL_SETTINGS: ShellSettings = {
|
|
22
|
+
defaultUiLanguage: 'en-US',
|
|
23
|
+
keepUserLoggedIn: false,
|
|
24
|
+
title: 'Crombolo',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LoginShellSettings {
|
|
28
|
+
usernameIsEmail: boolean
|
|
29
|
+
allowUserInvite: boolean
|
|
30
|
+
allowPasswordReset: boolean
|
|
31
|
+
disableLocalLogin: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Where to go once signed in. The authorize endpoint puts its own URL here, so
|
|
34
|
+
* the browser resumes the OAuth flow rather than landing on a blank editor.
|
|
35
|
+
*/
|
|
36
|
+
returnUrl?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const escapeHtml = (value: string): string =>
|
|
40
|
+
value.replace(
|
|
41
|
+
/[&<>"']/g,
|
|
42
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const noscript = (logoUrl: string): string => `<noscript>
|
|
46
|
+
<style>
|
|
47
|
+
#noscript-container { display: flex; flex-wrap: wrap; flex-direction: column;
|
|
48
|
+
align-items: center; justify-content: center; height: 100vh; padding: 0 20px;
|
|
49
|
+
text-align: center; }
|
|
50
|
+
</style>
|
|
51
|
+
<div id="noscript-container">
|
|
52
|
+
<h1 aria-hidden="true" class="uui-h3" style="display: inline-flex; align-items: center; gap: 10px">
|
|
53
|
+
<img alt="logo" src="${logoUrl}" style="width: 100%" />
|
|
54
|
+
</h1>
|
|
55
|
+
<p>For full functionality of Crombolo it is necessary to enable JavaScript.</p>
|
|
56
|
+
</div>
|
|
57
|
+
</noscript>`
|
|
58
|
+
|
|
59
|
+
/** The `<base href>` must end in a slash: the client router derives its base from it. */
|
|
60
|
+
export function renderBackOfficeShell(
|
|
61
|
+
paths: BackOfficePaths,
|
|
62
|
+
importmap: PackageManifestImportmap,
|
|
63
|
+
settings: ShellSettings = DEFAULT_SHELL_SETTINGS,
|
|
64
|
+
): string {
|
|
65
|
+
const assets = paths.assetsPath
|
|
66
|
+
const keepLoggedIn = settings.keepUserLoggedIn ? ' keep-user-logged-in' : ''
|
|
67
|
+
return `<!doctype html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="UTF-8" />
|
|
71
|
+
<base href="${paths.backOfficePath}/" />
|
|
72
|
+
<link rel="icon" type="image/svg+xml" href="${assets}/assets/favicon.svg" />
|
|
73
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
74
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
75
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
76
|
+
<meta name="pinterest" content="nopin" />
|
|
77
|
+
<meta name="application-name" content="${escapeHtml(settings.title)}" />
|
|
78
|
+
<title>${escapeHtml(settings.title)}</title>
|
|
79
|
+
<link rel="stylesheet" href="${assets}/css/umb-css.css" />
|
|
80
|
+
<link rel="stylesheet" href="${assets}/css/light.css" />
|
|
81
|
+
<link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
|
|
82
|
+
<script type="importmap">
|
|
83
|
+
${JSON.stringify(importmap, null, 2)}
|
|
84
|
+
</script>
|
|
85
|
+
<script type="module" src="${assets}/apps/app/app.element.js"></script>
|
|
86
|
+
</head>
|
|
87
|
+
<body class="uui-font uui-text" style="margin: 0; padding: 0; overflow: hidden">
|
|
88
|
+
${noscript(`${MANAGEMENT_API_PATH}/security/back-office/graphics/login-logo-alternative`)}
|
|
89
|
+
${
|
|
90
|
+
/* The client defaults this to '/umbraco' and builds its OAuth redirect_uri
|
|
91
|
+
and post-logout URL from it, so it must be told where the editor is. */ ''
|
|
92
|
+
}
|
|
93
|
+
<umb-app lang="${escapeHtml(settings.defaultUiLanguage)}" backoffice-path="${escapeHtml(paths.backOfficePath)}"${keepLoggedIn}></umb-app>
|
|
94
|
+
</body>
|
|
95
|
+
</html>
|
|
96
|
+
`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function renderLoginShell(
|
|
100
|
+
paths: BackOfficePaths,
|
|
101
|
+
importmap: PackageManifestImportmap,
|
|
102
|
+
settings: ShellSettings & LoginShellSettings,
|
|
103
|
+
): string {
|
|
104
|
+
const assets = paths.assetsPath
|
|
105
|
+
const graphics = `${MANAGEMENT_API_PATH}/security/back-office/graphics`
|
|
106
|
+
return `<!doctype html>
|
|
107
|
+
<html lang="en">
|
|
108
|
+
<head>
|
|
109
|
+
<meta charset="UTF-8" />
|
|
110
|
+
<base href="${paths.backOfficePath}/" />
|
|
111
|
+
<link rel="icon" type="image/svg+xml" href="${assets}/assets/favicon.svg" />
|
|
112
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
113
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
114
|
+
<meta name="application-name" content="${escapeHtml(settings.title)}" />
|
|
115
|
+
<title>${escapeHtml(settings.title)}</title>
|
|
116
|
+
<link rel="stylesheet" href="${assets}/css/light.css" />
|
|
117
|
+
<link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
|
|
118
|
+
<style>body { margin: 0; padding: 0; background-color: #fff7ea; }</style>
|
|
119
|
+
<script type="importmap">
|
|
120
|
+
${JSON.stringify(importmap, null, 2)}
|
|
121
|
+
</script>
|
|
122
|
+
<script type="module" src="${paths.backOfficePath}/login/login.js"></script>
|
|
123
|
+
</head>
|
|
124
|
+
<body class="uui-font uui-text" style="margin: 0; padding: 0; overflow: hidden">
|
|
125
|
+
${noscript(`${graphics}/login-logo-alternative`)}
|
|
126
|
+
<crombolo-login
|
|
127
|
+
lang="${escapeHtml(settings.defaultUiLanguage)}"
|
|
128
|
+
return-url="${escapeHtml(settings.returnUrl ?? paths.backOfficePath)}"
|
|
129
|
+
logo-image="${graphics}/login-logo"
|
|
130
|
+
logo-image-alternative="${graphics}/login-logo-alternative"
|
|
131
|
+
${settings.usernameIsEmail ? 'username-is-email' : ''}
|
|
132
|
+
${settings.allowUserInvite ? 'allow-user-invite' : ''}
|
|
133
|
+
${settings.allowPasswordReset ? 'allow-password-reset' : ''}
|
|
134
|
+
${settings.disableLocalLogin ? 'disable-local-login' : ''}>
|
|
135
|
+
</crombolo-login>
|
|
136
|
+
</body>
|
|
137
|
+
</html>
|
|
138
|
+
`
|
|
139
|
+
}
|
package/src/static.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static serving for the vendored client.
|
|
3
|
+
*
|
|
4
|
+
* Two behaviours are load-bearing: the cache-bust path segment is stripped
|
|
5
|
+
* before the file is looked up (Umbraco does this with a rewrite rule), and the
|
|
6
|
+
* hashed prefix gets immutable caching while the unhashed one does not.
|
|
7
|
+
*/
|
|
8
|
+
import { join, normalize } from 'node:path'
|
|
9
|
+
import { type InstalledExtension, resolveExtensionFile } from './extensions.ts'
|
|
10
|
+
import { brandedAsset } from './graphics.ts'
|
|
11
|
+
import type { BackOfficePaths } from './paths.ts'
|
|
12
|
+
import { VENDORED_ASSETS_PATH } from './paths.ts'
|
|
13
|
+
|
|
14
|
+
const IMMUTABLE = 'public, max-age=31536000, immutable'
|
|
15
|
+
|
|
16
|
+
export interface StaticOptions {
|
|
17
|
+
/** The installed npm extensions, which serve their own assets at /packages/. */
|
|
18
|
+
extensions?: readonly InstalledExtension[]
|
|
19
|
+
/** Serve the hashed prefix with immutable caching. Off in development. */
|
|
20
|
+
immutable?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Rejects any path that escapes the root once normalised. */
|
|
24
|
+
function safeJoin(root: string, relative: string): string | undefined {
|
|
25
|
+
const decoded = decodeURIComponent(relative)
|
|
26
|
+
if (decoded.includes('\0')) return undefined
|
|
27
|
+
const target = normalize(join(root, decoded))
|
|
28
|
+
const rootWithSep = root.endsWith('/') ? root : `${root}/`
|
|
29
|
+
return target.startsWith(rootWithSep) ? target : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StaticMatch {
|
|
33
|
+
file: string
|
|
34
|
+
cacheControl: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolves a request path to a file on disk, or undefined if this request is not
|
|
39
|
+
* ours. Strips the cache-bust segment, as Umbraco's rewrite does.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveStaticFile(
|
|
42
|
+
paths: BackOfficePaths,
|
|
43
|
+
pathname: string,
|
|
44
|
+
options: StaticOptions = {},
|
|
45
|
+
): StaticMatch | undefined {
|
|
46
|
+
const hashedPrefix = `${paths.assetsPath}/`
|
|
47
|
+
if (pathname.startsWith(hashedPrefix)) {
|
|
48
|
+
const relative = pathname.slice(hashedPrefix.length)
|
|
49
|
+
const branded = brandedAsset(relative)
|
|
50
|
+
if (branded) return { file: join(paths.pluginDir, branded), cacheControl: 'no-cache' }
|
|
51
|
+
const file = safeJoin(paths.vendorDir, relative)
|
|
52
|
+
if (!file) return undefined
|
|
53
|
+
return { file, cacheControl: options.immutable === false ? 'no-cache' : IMMUTABLE }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The vendored literal is an alias for wherever the editor is mounted: parts
|
|
57
|
+
// of the client hard-code `/umbraco/backoffice/…` for an asset — the header
|
|
58
|
+
// popover's logo, for one — and those requests arrive whatever the path is.
|
|
59
|
+
const plainPrefix = [`${paths.virtualDirectory}/`, `${VENDORED_ASSETS_PATH}/`].find((prefix) =>
|
|
60
|
+
pathname.startsWith(prefix),
|
|
61
|
+
)
|
|
62
|
+
if (plainPrefix) {
|
|
63
|
+
// A stale hash from a previous deploy lands here; serve it, but do not cache.
|
|
64
|
+
const rest = pathname.slice(plainPrefix.length)
|
|
65
|
+
const withoutStaleHash = /^[0-9a-f]{16}\//.test(rest) ? rest.slice(17) : rest
|
|
66
|
+
const branded = brandedAsset(withoutStaleHash)
|
|
67
|
+
if (branded) return { file: join(paths.pluginDir, branded), cacheControl: 'no-cache' }
|
|
68
|
+
const file = safeJoin(paths.vendorDir, withoutStaleHash)
|
|
69
|
+
if (!file) return undefined
|
|
70
|
+
return { file, cacheControl: 'no-cache' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pluginPrefix = `${paths.pluginPath}/`
|
|
74
|
+
if (pathname.startsWith(pluginPrefix)) {
|
|
75
|
+
const file = safeJoin(paths.pluginDir, pathname.slice(pluginPrefix.length))
|
|
76
|
+
if (!file) return undefined
|
|
77
|
+
return { file, cacheControl: 'no-cache' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// An installed extension's own files, resolved inside the package it names
|
|
81
|
+
// and nowhere else. `no-cache` for the same reason the framework plugin uses
|
|
82
|
+
// it: a version bump moves the file behind an unchanged path.
|
|
83
|
+
if (options.extensions && options.extensions.length > 0) {
|
|
84
|
+
const file = resolveExtensionFile(options.extensions, pathname)
|
|
85
|
+
if (file) return { file, cacheControl: 'no-cache' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Serves a resolved file, honouring If-None-Match. */
|
|
92
|
+
export async function serveStaticFile(
|
|
93
|
+
match: StaticMatch,
|
|
94
|
+
request: Request,
|
|
95
|
+
): Promise<Response | undefined> {
|
|
96
|
+
const file = Bun.file(match.file)
|
|
97
|
+
if (!(await file.exists())) return undefined
|
|
98
|
+
|
|
99
|
+
const etag = `W/"${file.size.toString(16)}-${Math.floor(file.lastModified).toString(16)}"`
|
|
100
|
+
const headers = new Headers({ etag, 'cache-control': match.cacheControl })
|
|
101
|
+
if (file.type) headers.set('content-type', file.type)
|
|
102
|
+
|
|
103
|
+
if (request.headers.get('if-none-match') === etag) {
|
|
104
|
+
return new Response(null, { status: 304, headers })
|
|
105
|
+
}
|
|
106
|
+
return new Response(file, { headers })
|
|
107
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `import x from './y.svg' with { type: 'text' }` — the file's contents as a
|
|
3
|
+
* string, which Bun inlines at bundle time.
|
|
4
|
+
*
|
|
5
|
+
* TypeScript has no knowledge of a bundler's loaders, so the shape has to be
|
|
6
|
+
* declared. Only `.svg` and `.css` are declared, and only `branding.ts` imports
|
|
7
|
+
* either: a wider declaration would make a mistyped asset import compile.
|
|
8
|
+
*
|
|
9
|
+
* `branding.ts` references this file directly, rather than relying on it being
|
|
10
|
+
* in anyone's `include`. The published package ships `.ts` source, so a
|
|
11
|
+
* consumer's own `tsc` compiles `branding.ts` — and without the reference it
|
|
12
|
+
* would compile it against no declaration and fail on an import it cannot
|
|
13
|
+
* explain, in a file the consumer does not own.
|
|
14
|
+
*/
|
|
15
|
+
declare module '*.svg' {
|
|
16
|
+
const contents: string
|
|
17
|
+
export default contents
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module '*.css' {
|
|
21
|
+
const contents: string
|
|
22
|
+
export default contents
|
|
23
|
+
}
|
package/README.md
DELETED