@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,298 @@
1
+ {
2
+ "$schema": "../../backoffice-dist/dist/umbraco-package-schema.json",
3
+ "name": "Crombolo",
4
+ "id": "Crombolo.Core",
5
+ "version": "0.9.1",
6
+ "allowPublicAccess": false,
7
+ "extensions": [
8
+ {
9
+ "type": "sectionView",
10
+ "alias": "Crombolo.SectionView.Bundles.Marketplace",
11
+ "name": "Crombolo Packages Marketplace",
12
+ "element": "/crombolo-plugin/bundles-marketplace.js",
13
+ "weight": 300,
14
+ "overwrites": ["Umb.SectionView.Packages.Marketplace"],
15
+ "meta": {
16
+ "label": "Marketplace",
17
+ "pathname": "packages",
18
+ "icon": "icon-cloud"
19
+ },
20
+ "conditions": [
21
+ {
22
+ "alias": "Umb.Condition.SectionAlias",
23
+ "match": "Umb.Section.Packages"
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "type": "sectionView",
29
+ "alias": "Crombolo.SectionView.Bundles.Created",
30
+ "name": "Crombolo Created Bundles",
31
+ "element": "/crombolo-plugin/bundles-created.js",
32
+ "weight": 100,
33
+ "overwrites": ["Umb.SectionView.Packages.Builder"],
34
+ "meta": {
35
+ "label": "Created",
36
+ "pathname": "created",
37
+ "icon": "icon-files"
38
+ },
39
+ "conditions": [
40
+ {
41
+ "alias": "Umb.Condition.SectionAlias",
42
+ "match": "Umb.Section.Packages"
43
+ }
44
+ ]
45
+ },
46
+ {
47
+ "type": "sectionView",
48
+ "alias": "Crombolo.SectionView.Bundles.Installed",
49
+ "name": "Crombolo Installed Packages",
50
+ "element": "/crombolo-plugin/bundles-installed.js",
51
+ "weight": 200,
52
+ "overwrites": ["Umb.SectionView.Packages.Installed"],
53
+ "meta": {
54
+ "label": "Installed",
55
+ "pathname": "installed",
56
+ "icon": "icon-box"
57
+ },
58
+ "conditions": [
59
+ {
60
+ "alias": "Umb.Condition.SectionAlias",
61
+ "match": "Umb.Section.Packages"
62
+ }
63
+ ]
64
+ },
65
+ {
66
+ "type": "dashboard",
67
+ "alias": "Crombolo.Dashboard.Changes",
68
+ "name": "Crombolo Changes Dashboard",
69
+ "element": "/crombolo-plugin/changes-dashboard.js",
70
+ "weight": 5,
71
+ "meta": {
72
+ "label": "Changes",
73
+ "pathname": "changes"
74
+ },
75
+ "conditions": [
76
+ {
77
+ "alias": "Umb.Condition.SectionAlias",
78
+ "match": "Umb.Section.Settings"
79
+ }
80
+ ]
81
+ },
82
+ {
83
+ "type": "dashboard",
84
+ "alias": "Crombolo.Dashboard.Welcome",
85
+ "name": "Crombolo Welcome Dashboard",
86
+ "element": "/crombolo-plugin/welcome-dashboard.js",
87
+ "weight": 20,
88
+ "overwrites": ["Umb.Dashboard.UmbracoNews"],
89
+ "meta": {
90
+ "label": "Welcome",
91
+ "pathname": "welcome"
92
+ },
93
+ "conditions": [
94
+ {
95
+ "alias": "Umb.Condition.SectionAlias",
96
+ "match": "Umb.Section.Content"
97
+ }
98
+ ]
99
+ },
100
+ {
101
+ "type": "dashboard",
102
+ "alias": "Crombolo.Dashboard.Settings",
103
+ "name": "Crombolo Settings Dashboard",
104
+ "element": "/crombolo-plugin/settings-dashboard.js",
105
+ "weight": 500,
106
+ "overwrites": ["Umb.Dashboard.SettingsWelcome"],
107
+ "meta": {
108
+ "label": "Welcome",
109
+ "pathname": "welcome"
110
+ },
111
+ "conditions": [
112
+ {
113
+ "alias": "Umb.Condition.SectionAlias",
114
+ "match": "Umb.Section.Settings"
115
+ }
116
+ ]
117
+ },
118
+ {
119
+ "type": "backofficeEntryPoint",
120
+ "alias": "Crombolo.EntryPoint.TsxEditors",
121
+ "name": "Crombolo TSX Template and Partial View Editors",
122
+ "js": "/crombolo-plugin/tsx-editors.js"
123
+ },
124
+ {
125
+ "type": "headerApp",
126
+ "alias": "Crombolo.HeaderApp.ReadOnly",
127
+ "name": "Crombolo Read-only Banner",
128
+ "element": "/crombolo-plugin/read-only-banner.js",
129
+ "weight": 1000
130
+ },
131
+ {
132
+ "type": "workspaceFooterApp",
133
+ "alias": "Crombolo.WorkspaceFooterApp.ElementTypeHint",
134
+ "name": "Crombolo Element Type Library Hint",
135
+ "element": "/crombolo-plugin/element-type-hint.js",
136
+ "weight": 900,
137
+ "conditions": [
138
+ {
139
+ "alias": "Umb.Condition.WorkspaceAlias",
140
+ "match": "Umb.Workspace.DocumentType"
141
+ }
142
+ ]
143
+ },
144
+ {
145
+ "type": "localization",
146
+ "alias": "Crombolo.Localization.EN",
147
+ "name": "Crombolo English Overrides",
148
+ "weight": 0,
149
+ "meta": {
150
+ "culture": "en"
151
+ },
152
+ "js": "/crombolo-plugin/branding/localization-en.js"
153
+ },
154
+ {
155
+ "type": "localization",
156
+ "alias": "Crombolo.Localization.EN_US",
157
+ "name": "Crombolo English (United States) Overrides",
158
+ "weight": 0,
159
+ "meta": {
160
+ "culture": "en-us"
161
+ },
162
+ "js": "/crombolo-plugin/branding/localization-en.js"
163
+ },
164
+ {
165
+ "type": "backofficeEntryPoint",
166
+ "alias": "Crombolo.EntryPoint.Branding",
167
+ "name": "Crombolo Branding",
168
+ "js": "/crombolo-plugin/branding/branding.js"
169
+ },
170
+ {
171
+ "type": "backofficeEntryPoint",
172
+ "alias": "Crombolo.EntryPoint.HelpMenu",
173
+ "name": "Crombolo Help Menu",
174
+ "js": "/crombolo-plugin/branding/help-menu.js"
175
+ },
176
+ {
177
+ "type": "backofficeEntryPoint",
178
+ "alias": "Crombolo.EntryPoint.Sysinfo",
179
+ "name": "Crombolo System Information Branding",
180
+ "js": "/crombolo-plugin/branding/sysinfo.js"
181
+ },
182
+ {
183
+ "type": "section",
184
+ "alias": "Crombolo.Section.Forms",
185
+ "name": "Crombolo Forms Section",
186
+ "weight": 50,
187
+ "meta": {
188
+ "label": "Forms",
189
+ "pathname": "forms"
190
+ }
191
+ },
192
+ {
193
+ "type": "sectionView",
194
+ "alias": "Crombolo.SectionView.Forms.Overview",
195
+ "name": "Crombolo Forms Overview",
196
+ "element": "/crombolo-plugin/forms-overview.js",
197
+ "weight": 200,
198
+ "meta": {
199
+ "label": "Forms",
200
+ "pathname": "forms",
201
+ "icon": "icon-notepad"
202
+ },
203
+ "conditions": [
204
+ {
205
+ "alias": "Umb.Condition.SectionAlias",
206
+ "match": "Crombolo.Section.Forms"
207
+ }
208
+ ]
209
+ },
210
+ {
211
+ "type": "sectionView",
212
+ "alias": "Crombolo.SectionView.Forms.Entries",
213
+ "name": "Crombolo Forms Entries",
214
+ "element": "/crombolo-plugin/forms-entries.js",
215
+ "weight": 100,
216
+ "meta": {
217
+ "label": "Entries",
218
+ "pathname": "entries",
219
+ "icon": "icon-inbox"
220
+ },
221
+ "conditions": [
222
+ {
223
+ "alias": "Umb.Condition.SectionAlias",
224
+ "match": "Crombolo.Section.Forms"
225
+ }
226
+ ]
227
+ },
228
+ {
229
+ "type": "propertyEditorSchema",
230
+ "name": "Form Picker",
231
+ "alias": "Crombolo.FormPicker",
232
+ "meta": {
233
+ "defaultPropertyEditorUiAlias": "Crombolo.PropertyEditorUi.FormPicker"
234
+ }
235
+ },
236
+ {
237
+ "type": "propertyEditorUi",
238
+ "alias": "Crombolo.PropertyEditorUi.FormPicker",
239
+ "name": "Form Picker",
240
+ "element": "/crombolo-plugin/form-picker.js",
241
+ "meta": {
242
+ "label": "Form Picker",
243
+ "icon": "icon-notepad",
244
+ "group": "pickers",
245
+ "propertyEditorSchemaAlias": "Crombolo.FormPicker"
246
+ }
247
+ },
248
+ {
249
+ "type": "backofficeEntryPoint",
250
+ "alias": "Crombolo.EntryPoint.ClientCredentials",
251
+ "name": "Crombolo Client Credential Prefix",
252
+ "js": "/crombolo-plugin/branding/client-credentials.js"
253
+ },
254
+ {
255
+ "type": "backofficeEntryPoint",
256
+ "alias": "Crombolo.EntryPoint.BundleBuilderLabels",
257
+ "name": "Crombolo Bundle Builder Labels",
258
+ "js": "/crombolo-plugin/branding/bundle-builder-labels.js"
259
+ },
260
+ {
261
+ "type": "backofficeEntryPoint",
262
+ "alias": "Crombolo.EntryPoint.ComponentsTree",
263
+ "name": "Crombolo Components Tree",
264
+ "js": "/crombolo-plugin/branding/components-tree.js"
265
+ },
266
+ {
267
+ "type": "backofficeEntryPoint",
268
+ "alias": "Crombolo.EntryPoint.ContentTypeLabels",
269
+ "name": "Crombolo Content Type Labels",
270
+ "js": "/crombolo-plugin/branding/content-type-labels.js"
271
+ },
272
+ {
273
+ "type": "backofficeEntryPoint",
274
+ "alias": "Crombolo.EntryPoint.QueryBuilder",
275
+ "name": "Crombolo Query Builder Language",
276
+ "js": "/crombolo-plugin/branding/query-builder.js"
277
+ },
278
+ {
279
+ "type": "backofficeEntryPoint",
280
+ "alias": "Crombolo.EntryPoint.SignIn",
281
+ "name": "Crombolo Sign-in Button",
282
+ "js": "/crombolo-plugin/branding/sign-in.js"
283
+ },
284
+ {
285
+ "type": "backofficeEntryPoint",
286
+ "alias": "Crombolo.EntryPoint.LogViewerMenu",
287
+ "name": "Crombolo Log Viewer Search Menu",
288
+ "js": "/crombolo-plugin/branding/log-viewer-menu.js"
289
+ },
290
+ {
291
+ "type": "globalContext",
292
+ "alias": "Crombolo.GlobalContext.Icons",
293
+ "name": "Crombolo Icons Context",
294
+ "overwrites": ["Umb.GlobalContext.Icons"],
295
+ "api": "/crombolo-plugin/branding/icon-registry.js"
296
+ }
297
+ ]
298
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The Welcome dashboard in the Content section. Its manifest declares
3
+ * `overwrites: ['Umb.Dashboard.UmbracoNews']`, which takes the place of
4
+ * Umbraco's news dashboard without mutating the registry while the backoffice
5
+ * is rendering — the hazard the TSX entry point documents.
6
+ */
7
+ // UmbLitElement, not plain LitElement: a dashboard is used as a controller host,
8
+ // and a plain element fails with `controllerHost.provideContext is not a function`.
9
+ import { css, html } from '@umbraco-cms/backoffice/external/lit'
10
+ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
11
+
12
+ const STEPS = [
13
+ {
14
+ title: 'Model the content',
15
+ body: html`Open <strong>Settings → Content Types</strong> and describe a page: its
16
+ properties, its tabs, and which types may sit beneath it. This is the same
17
+ editor Umbraco ships, so anything you know about Umbraco document types applies.`,
18
+ },
19
+ {
20
+ title: 'Write a template',
21
+ body: html`Templates are <strong>TSX</strong>, not Razor. A template is a file in
22
+ <code>Views/</code> that default-exports a component and receives the page as
23
+ <code>model</code>; <code>export const layout = 'alias'</code> names its master.
24
+ The editor under Settings → Templates writes that shape for you.`,
25
+ },
26
+ {
27
+ title: 'Create and publish',
28
+ body: html`Back in <strong>Content</strong>, create a page from your content type, fill it
29
+ in and publish. Publishing is per culture, and the published version is what the
30
+ site renders — drafts stay invisible until you say otherwise.`,
31
+ },
32
+ {
33
+ title: 'Keep the schema in the repository',
34
+ body: html`Content types, data types and templates are also files:
35
+ <code>schema/*.toml</code>. <code>crombolo schema sync</code> writes changes made in
36
+ the backoffice back out, and <code>crombolo schema check</code> tells you whether a
37
+ database matches. Schema travels through Git, not a database export.`,
38
+ },
39
+ ]
40
+
41
+ export default class CromboloWelcomeDashboardElement extends UmbLitElement {
42
+ render() {
43
+ return html`
44
+ <uui-box headline="Welcome to Crombolo">
45
+ <p class="lede">
46
+ Crombolo is a content management system that runs on
47
+ <strong>Bun and TypeScript</strong>. It serves the real Umbraco backoffice you
48
+ are looking at now and follows Umbraco's API contract, so the editing
49
+ experience is Umbraco's — while the server, the templates and the schema are
50
+ TypeScript, and the database is SQLite or Postgres.
51
+ </p>
52
+ <p class="lede">
53
+ There is no .NET anywhere in it: templates are TSX rather than Razor, and a
54
+ site's schema lives in TOML files beside its code.
55
+ </p>
56
+ </uui-box>
57
+
58
+ <uui-box headline="Getting started">
59
+ <ol>
60
+ ${STEPS.map(
61
+ (step) => html`<li>
62
+ <h4>${step.title}</h4>
63
+ <p>${step.body}</p>
64
+ </li>`,
65
+ )}
66
+ </ol>
67
+ </uui-box>
68
+
69
+ <uui-box headline="Worth knowing">
70
+ <ul>
71
+ <li>
72
+ The <strong>Settings</strong> section holds templates, partial views,
73
+ stylesheets and scripts — all served from your site's folders.
74
+ </li>
75
+ <li>
76
+ <strong>Upgrade</strong>, also under Settings, reports anything a version
77
+ change needs from you before it will run.
78
+ </li>
79
+ <li>
80
+ The log viewer under Settings reads the server's own structured logs, so a
81
+ failed publish or a broken template is searchable rather than guessed at.
82
+ </li>
83
+ </ul>
84
+ </uui-box>
85
+ `
86
+ }
87
+
88
+ static styles = css`
89
+ :host { display: block; padding: var(--uui-size-layout-1); }
90
+ uui-box { margin-bottom: var(--uui-size-layout-1); }
91
+ .lede { max-width: 70ch; line-height: 1.6; }
92
+ ol { margin: 0; padding-left: var(--uui-size-layout-1); }
93
+ ol li { margin-bottom: var(--uui-size-space-5); max-width: 70ch; }
94
+ ol h4 { margin: 0 0 var(--uui-size-space-2); }
95
+ ol p { margin: 0; line-height: 1.6; }
96
+ ul { margin: 0; padding-left: var(--uui-size-layout-1); }
97
+ ul li { margin-bottom: var(--uui-size-space-3); max-width: 70ch; line-height: 1.6; }
98
+ code {
99
+ background: var(--uui-color-surface-alt);
100
+ padding: 0 var(--uui-size-space-1);
101
+ border-radius: var(--uui-border-radius);
102
+ }
103
+ `
104
+ }
105
+
106
+ customElements.define('crombolo-welcome-dashboard', CromboloWelcomeDashboardElement)
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The brand as data: the marks, and the palette behind them.
3
+ *
4
+ * The back office serves these as files, by path (`graphics.ts`). This exports
5
+ * the same bytes to anything that has to *draw* the brand rather than serve it
6
+ * — the enterprise console, which is a separate product surface in another
7
+ * repository and reaches the CMS only through the `@crombolo/crombolo` umbrella.
8
+ *
9
+ * Imported as text rather than duplicated as string literals, so the `.svg`
10
+ * and `.css` files stay the one source of truth and a new logo is one file to
11
+ * change. Bun inlines a text import at bundle time, so this works in a browser
12
+ * bundle as well as on the server — which matters, because the console that
13
+ * consumes it is a client-side app and could not read a file at runtime.
14
+ */
15
+ /// <reference path="./text-imports.d.ts" />
16
+ import faviconText from '../plugin/branding/favicon.svg' with { type: 'text' }
17
+ import logoText from '../plugin/branding/logo-header.svg' with { type: 'text' }
18
+ import themeText from '../plugin/branding/theme.css' with { type: 'text' }
19
+
20
+ /** The mark and wordmark, for a header or a sign-in card. */
21
+ export const logoSvg: string = logoText.trim()
22
+
23
+ /** The mark alone, square, for a tab icon. */
24
+ export const faviconSvg: string = faviconText.trim()
25
+
26
+ /**
27
+ * The palette, read out of `theme.css` rather than restated here.
28
+ *
29
+ * The theme is what the back office is actually painted with, so parsing it is
30
+ * what makes a consumer's colours the same colours rather than a copy that was
31
+ * the same once. Only the `--crombolo-*` tokens are ours; everything else in
32
+ * that file is an Umbraco variable being pointed at one of them.
33
+ */
34
+ export interface BrandPalette {
35
+ orange: string
36
+ orangeLight: string
37
+ orangeDeep: string
38
+ orangeText: string
39
+ cocoa: string
40
+ cream: string
41
+ }
42
+
43
+ /** `--crombolo-orange-deep` → `orangeDeep`. */
44
+ const camel = (name: string) =>
45
+ name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
46
+
47
+ export function paletteFrom(css: string): BrandPalette {
48
+ const found: Record<string, string> = {}
49
+ for (const [, name, value] of css.matchAll(/--crombolo-([a-z-]+)\s*:\s*([^;]+);/g))
50
+ found[camel(name as string)] = (value as string).trim()
51
+ const wanted = ['orange', 'orangeLight', 'orangeDeep', 'orangeText', 'cocoa', 'cream']
52
+ const missing = wanted.filter((key) => found[key] === undefined)
53
+ // A theme that stopped declaring one of these would otherwise hand a
54
+ // consumer `undefined` and paint something invisible.
55
+ if (missing.length > 0) throw new Error(`theme.css declares no ${missing.join(', ')}.`)
56
+ return Object.fromEntries(wanted.map((key) => [key, found[key]])) as unknown as BrandPalette
57
+ }
58
+
59
+ export const brandPalette: BrandPalette = paletteFrom(themeText)
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Backoffice extensions as npm dependencies. `docs/17-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 { existsSync, readFileSync } from 'node:fs'
14
+ import { dirname, join, normalize, sep } from 'node:path'
15
+ import type { PackageManifest, PackageManifestImportmap } from './manifests.ts'
16
+
17
+ /** Where an installed bundle's assets are served from. */
18
+ export const EXTENSION_PATH = '/bundles'
19
+
20
+ /** The npm keyword a bundle publishes so the marketplace can find it. */
21
+ export const EXTENSION_KEYWORD = 'crombolo-bundle'
22
+
23
+ /**
24
+ * What a dependency declares to become a backoffice extension. The shape is
25
+ * Umbraco's manifest minus the parts the server owns — the name and version
26
+ * come from the package itself.
27
+ */
28
+ export interface CromboloField {
29
+ id?: string
30
+ name?: string
31
+ allowPublicAccess?: boolean
32
+ allowTelemetry?: boolean
33
+ extensions?: unknown[]
34
+ importmap?: PackageManifestImportmap
35
+ }
36
+
37
+ export interface InstalledExtension {
38
+ /** The npm package name, which is also its segment in the asset path. */
39
+ packageName: string
40
+ version: string
41
+ directory: string
42
+ manifest: PackageManifest
43
+ }
44
+
45
+ /**
46
+ * Asset references inside an extension. Only these carry a path; rewriting
47
+ * every string would corrupt labels and aliases that happen to look like one.
48
+ */
49
+ const ASSET_KEYS = new Set(['element', 'api', 'js', 'loader'])
50
+
51
+ interface PackageJson {
52
+ name?: string
53
+ version?: string
54
+ dependencies?: Record<string, string>
55
+ crombolo?: CromboloField
56
+ }
57
+
58
+ function readJson(file: string): PackageJson | undefined {
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown
61
+ return parsed && typeof parsed === 'object' ? (parsed as PackageJson) : undefined
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The directory a dependency resolves to, walking up as Node does, so a
69
+ * workspace root's hoisted `node_modules` is found from a site inside it.
70
+ */
71
+ export function resolvePackageDir(from: string, name: string): string | undefined {
72
+ // A name may not climb out of node_modules, whatever a package.json says.
73
+ if (name.includes('..') || name.startsWith('/') || name.startsWith('.')) return undefined
74
+ let dir = normalize(from)
75
+ for (;;) {
76
+ const candidate = join(dir, 'node_modules', name)
77
+ if (existsSync(join(candidate, 'package.json'))) return candidate
78
+ const parent = dirname(dir)
79
+ if (parent === dir) return undefined
80
+ dir = parent
81
+ }
82
+ }
83
+
84
+ /** `dist/seo.js` inside `@acme/seo` becomes `/bundles/@acme/seo/dist/seo.js`. */
85
+ const assetPath = (packageName: string, target: string): string =>
86
+ target.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(target)
87
+ ? target
88
+ : `${EXTENSION_PATH}/${packageName}/${target.replace(/^\.?\//, '')}`
89
+
90
+ function rewriteAssets(value: unknown, packageName: string): unknown {
91
+ if (Array.isArray(value)) return value.map((item) => rewriteAssets(item, packageName))
92
+ if (!value || typeof value !== 'object') return value
93
+ const out: Record<string, unknown> = {}
94
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
95
+ out[key] =
96
+ ASSET_KEYS.has(key) && typeof item === 'string'
97
+ ? assetPath(packageName, item)
98
+ : rewriteAssets(item, packageName)
99
+ }
100
+ return out
101
+ }
102
+
103
+ function rewriteImportmap(
104
+ importmap: PackageManifestImportmap | undefined,
105
+ packageName: string,
106
+ ): PackageManifestImportmap | undefined {
107
+ if (!importmap) return undefined
108
+ const imports: Record<string, string> = {}
109
+ for (const [specifier, target] of Object.entries(importmap.imports ?? {}))
110
+ imports[specifier] = assetPath(packageName, target)
111
+ const scopes: Record<string, Record<string, string>> = {}
112
+ for (const [scope, scopeImports] of Object.entries(importmap.scopes ?? {})) {
113
+ const rewritten: Record<string, string> = {}
114
+ for (const [specifier, target] of Object.entries(scopeImports))
115
+ rewritten[specifier] = assetPath(packageName, target)
116
+ scopes[assetPath(packageName, scope)] = rewritten
117
+ }
118
+ return Object.keys(scopes).length > 0 ? { imports, scopes } : { imports }
119
+ }
120
+
121
+ /** One dependency's manifest, or undefined when it declares no extensions. */
122
+ export function readExtension(
123
+ directory: string,
124
+ packageName: string,
125
+ ): InstalledExtension | undefined {
126
+ const json = readJson(join(directory, 'package.json'))
127
+ const field = json?.crombolo
128
+ if (!field || !Array.isArray(field.extensions) || field.extensions.length === 0) return undefined
129
+ return {
130
+ packageName,
131
+ version: json?.version ?? '0.0.0',
132
+ directory,
133
+ manifest: {
134
+ name: field.name ?? packageName,
135
+ id: field.id ?? packageName,
136
+ version: json?.version ?? '0.0.0',
137
+ allowPublicAccess: field.allowPublicAccess ?? false,
138
+ allowTelemetry: field.allowTelemetry ?? false,
139
+ extensions: field.extensions.map((extension) => rewriteAssets(extension, packageName)),
140
+ importmap: rewriteImportmap(field.importmap, packageName),
141
+ },
142
+ }
143
+ }
144
+
145
+ /** Every declared dependency of the site that is a backoffice extension. */
146
+ export function discoverExtensions(siteDir: string): InstalledExtension[] {
147
+ const json = readJson(join(siteDir, 'package.json'))
148
+ if (!json?.dependencies) return []
149
+ const found: InstalledExtension[] = []
150
+ for (const name of Object.keys(json.dependencies).sort()) {
151
+ const directory = resolvePackageDir(siteDir, name)
152
+ if (!directory) continue
153
+ const extension = readExtension(directory, name)
154
+ if (extension) found.push(extension)
155
+ }
156
+ return found
157
+ }
158
+
159
+ /**
160
+ * The package a `/packages/…` request names, and the file inside it.
161
+ *
162
+ * The name is matched against the site's own extensions rather than trusted, so
163
+ * this cannot be used to read an arbitrary file out of `node_modules`; a scoped
164
+ * name takes two segments, as npm writes it.
165
+ */
166
+ export function resolveExtensionFile(
167
+ extensions: readonly InstalledExtension[],
168
+ pathname: string,
169
+ ): string | undefined {
170
+ const prefix = `${EXTENSION_PATH}/`
171
+ if (!pathname.startsWith(prefix)) return undefined
172
+ const rest = pathname.slice(prefix.length)
173
+ const segments = rest.split('/')
174
+ const take = rest.startsWith('@') || rest.toLowerCase().startsWith('%40') ? 2 : 1
175
+ if (segments.length <= take) return undefined
176
+ // Decoded before matching, so `%40acme/seo` finds the same package the
177
+ // manifest names. Safe because the name is only compared against the known
178
+ // extensions, never joined into a path — the file part is what gets joined.
179
+ let name: string
180
+ try {
181
+ name = decodeURIComponent(segments.slice(0, take).join('/'))
182
+ } catch {
183
+ return undefined
184
+ }
185
+ const relative = segments.slice(take).join('/')
186
+ const extension = extensions.find((candidate) => candidate.packageName === name)
187
+ if (!extension || relative.length === 0) return undefined
188
+
189
+ const decoded = decodeURIComponent(relative)
190
+ if (decoded.includes('\0')) return undefined
191
+ const root = extension.directory.endsWith(sep)
192
+ ? extension.directory
193
+ : `${extension.directory}${sep}`
194
+ const target = normalize(join(extension.directory, decoded))
195
+ return target.startsWith(root) ? target : undefined
196
+ }
197
+
198
+ export interface ExtensionRegistry {
199
+ list(): readonly InstalledExtension[]
200
+ /** Called after an install or an uninstall, so the next read sees it. */
201
+ invalidate(): void
202
+ }
203
+
204
+ /**
205
+ * Discovery, memoized.
206
+ *
207
+ * Reading a handful of `package.json` files on every manifest request would be
208
+ * wasteful, but the uncached behaviour is load-bearing in development: an author
209
+ * editing a local extension expects a reload to show it, which is why `cache`
210
+ * is off there rather than everywhere.
211
+ */
212
+ export function createExtensionRegistry(
213
+ siteDir: string,
214
+ options: { cache?: boolean } = {},
215
+ ): ExtensionRegistry {
216
+ let cached: InstalledExtension[] | undefined
217
+ const cache = options.cache ?? true
218
+ return {
219
+ list() {
220
+ if (!cache) return discoverExtensions(siteDir)
221
+ cached ??= discoverExtensions(siteDir)
222
+ return cached
223
+ },
224
+ invalidate() {
225
+ cached = undefined
226
+ },
227
+ }
228
+ }