@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.
Files changed (87) 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-client.js +34 -0
  27. package/plugin/bundles-created-overview.js +150 -0
  28. package/plugin/bundles-created.js +70 -0
  29. package/plugin/bundles-installed.js +147 -0
  30. package/plugin/bundles-marketplace.js +199 -0
  31. package/plugin/changes-dashboard.js +132 -0
  32. package/plugin/element-type-hint.js +58 -0
  33. package/plugin/form-picker.js +116 -0
  34. package/plugin/forms-client.js +52 -0
  35. package/plugin/forms-entries.js +244 -0
  36. package/plugin/forms-overview.js +380 -0
  37. package/plugin/localizations/ar.js +97 -0
  38. package/plugin/localizations/bs.js +86 -0
  39. package/plugin/localizations/cs.js +73 -0
  40. package/plugin/localizations/cy.js +92 -0
  41. package/plugin/localizations/da.js +101 -0
  42. package/plugin/localizations/de.js +79 -0
  43. package/plugin/localizations/en.js +71 -0
  44. package/plugin/localizations/es.js +52 -0
  45. package/plugin/localizations/fr.js +78 -0
  46. package/plugin/localizations/he.js +24 -0
  47. package/plugin/localizations/hr.js +89 -0
  48. package/plugin/localizations/it.js +86 -0
  49. package/plugin/localizations/ja.js +43 -0
  50. package/plugin/localizations/ko.js +24 -0
  51. package/plugin/localizations/nb.js +44 -0
  52. package/plugin/localizations/nl.js +85 -0
  53. package/plugin/localizations/pl.js +44 -0
  54. package/plugin/localizations/pt-br.js +25 -0
  55. package/plugin/localizations/pt.js +100 -0
  56. package/plugin/localizations/ro.js +9 -0
  57. package/plugin/localizations/ru.js +46 -0
  58. package/plugin/localizations/sv.js +46 -0
  59. package/plugin/localizations/tr.js +80 -0
  60. package/plugin/localizations/uk.js +46 -0
  61. package/plugin/localizations/umbraco-package.json +279 -0
  62. package/plugin/localizations/vi.js +104 -0
  63. package/plugin/localizations/zh-tw.js +38 -0
  64. package/plugin/localizations/zh.js +38 -0
  65. package/plugin/read-only-banner.js +48 -0
  66. package/plugin/report-client.js +13 -0
  67. package/plugin/seo/client.js +35 -0
  68. package/plugin/seo/dashboard.js +242 -0
  69. package/plugin/seo/document-view.js +161 -0
  70. package/plugin/seo/type-hint.js +45 -0
  71. package/plugin/seo/umbraco-package.json +61 -0
  72. package/plugin/settings-dashboard.js +123 -0
  73. package/plugin/tsx-editors.js +337 -0
  74. package/plugin/tsx-snippets.js +63 -0
  75. package/plugin/umbraco-package.json +298 -0
  76. package/plugin/welcome-dashboard.js +106 -0
  77. package/src/branding.ts +59 -0
  78. package/src/extensions.ts +228 -0
  79. package/src/graphics.ts +87 -0
  80. package/src/index.ts +7 -0
  81. package/src/login-assets.ts +18 -0
  82. package/src/manifests.ts +154 -0
  83. package/src/paths.ts +117 -0
  84. package/src/shell.ts +139 -0
  85. package/src/static.ts +107 -0
  86. package/src/text-imports.d.ts +23 -0
  87. package/README.md +0 -3
@@ -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,7 @@
1
+ export * from './extensions.ts'
2
+ export * from './graphics.ts'
3
+ export * from './login-assets.ts'
4
+ export * from './manifests.ts'
5
+ export * from './paths.ts'
6
+ export * from './shell.ts'
7
+ 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
+ }
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.