@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,337 @@
1
+ /**
2
+ * Makes Umbraco's template and partial view editors write and read TSX instead
3
+ * of Razor. Umbraco's client hard-codes a Razor scaffold for a new template,
4
+ * writes a Razor `Layout = "x.cshtml"` block when a master template is chosen,
5
+ * starts a new partial view empty, and opens both in an editor configured for
6
+ * Razor. This entry point changes the methods that do so — the template
7
+ * repository's scaffold, the template workspace's master-template update, the
8
+ * partial view repository's scaffold and the code editor's own language — to
9
+ * write what a crombolo view is: a default-exported component, with
10
+ * `export const layout = '<alias>'` for its master, and for a partial view the
11
+ * server's `Empty` snippet. Snippets inserted into a view are translated to TSX
12
+ * (`tsx-snippets.js`), and the inserts that have no TSX equivalent — a partial
13
+ * view, which needs an import as well as a tag, and Razor's sections — are hidden.
14
+ *
15
+ * The methods are patched on Umbraco's own classes rather than the extensions
16
+ * re-registered: changing the registry once the backoffice is rendering makes
17
+ * every open section re-evaluate its extensions, and a route set up in that
18
+ * window throws inside Umbraco's code.
19
+ */
20
+ import { UmbCodeEditorElement } from '@umbraco-cms/backoffice/code-editor'
21
+ import { PartialViewService } from '@umbraco-cms/backoffice/external/backend-api'
22
+ import { UmbPartialViewDetailRepository } from '@umbraco-cms/backoffice/partial-view'
23
+ import { UmbTemplateDetailRepository } from '@umbraco-cms/backoffice/template'
24
+ import { toTsxSnippet } from './tsx-snippets.js'
25
+
26
+ export const SCAFFOLD = `import type { PageProps } from '@crombolo/crombolo'
27
+
28
+ export default function Template({ model }: PageProps) {
29
+ return (
30
+ <div>
31
+ <h1>{model.name}</h1>
32
+ </div>
33
+ )
34
+ }
35
+ `
36
+
37
+ const LAYOUT_EXPORT = /^export\s+const\s+layout\s*=\s*['"][^'"]*['"]\s*;?[ \t]*\n*/m
38
+
39
+ /**
40
+ * Umbraco's own Razor layout block, the same shape its workspace writes and
41
+ * matches. A TSX view can never legitimately hold one, and the patch below cannot
42
+ * guarantee it is in place before Umbraco's `create()` runs — that method calls
43
+ * `setLayoutTemplate(parent, true)` itself, so on a slow load the unpatched writer
44
+ * can insert a block (with `undefined` for the alias it has not fetched yet)
45
+ * before this module has replaced the method. Stripping it here makes the result
46
+ * the same whichever of the two ran first.
47
+ */
48
+ const LAYOUT_BLOCK = /@\{[\s\S]*?Layout\s*=\s*(?:"[^"]*"|null)\s*;[\s\S]*?\}[ \t]*\n*/g
49
+
50
+ /** `content` with its `export const layout` set to `alias`, or removed when null. */
51
+ export function withLayout(content, alias) {
52
+ const rest = content.replace(LAYOUT_BLOCK, '').replace(LAYOUT_EXPORT, '')
53
+ if (!alias) return rest
54
+ const line = `export const layout = '${alias}'\n\n`
55
+ // After the imports, where a view declares it
56
+ const imports = /^(?:import[^\n]*\n)+\n*/.exec(rest)
57
+ if (!imports) return line + rest
58
+ return `${imports[0].replace(/\n*$/, '\n\n')}${line}${rest.slice(imports[0].length)}`
59
+ }
60
+
61
+ /**
62
+ * The dialect a crombolo view is written in. Accepted on `umb-code-editor`'s
63
+ * `language` in place of one of Umbraco's own, and what Umbraco's hard-coded
64
+ * `razor` is read as.
65
+ *
66
+ * monaco has no `tsx` language of its own, and registering one would be a step
67
+ * backwards: TypeScript decides whether a file may contain JSX from its *file
68
+ * name*, and its worker only attaches to the `typescript` and `javascript`
69
+ * modes. A `tsx` language would therefore tokenise and then be ignored by the
70
+ * service every completion and every real error comes from. So the dialect is
71
+ * monaco's `typescript` mode plus a model whose URI ends in `.tsx`.
72
+ */
73
+ export const TSX = 'tsx'
74
+
75
+ /**
76
+ * The virtual directory a view is opened in. It mirrors `Views/` on disk so that
77
+ * a view's `../schema/content-types.d.ts` resolves in the editor exactly as it
78
+ * resolves for `tsc`.
79
+ */
80
+ const VIEWS = 'file:///Views'
81
+
82
+ /**
83
+ * `bundler` module resolution, which is what the site's tsconfig uses and what
84
+ * resolves the served module. monaco's editor-side `ModuleResolutionKind` names
85
+ * only Classic and NodeJs — the enum predates the option — but the worker's own
86
+ * TypeScript knows it, and monaco passes the compiler options through untouched.
87
+ */
88
+ const BUNDLER = 100
89
+
90
+ /** Code editors showing a view, rather than a stylesheet, a script or JSON. */
91
+ const views = new WeakSet()
92
+
93
+ let opened = 0
94
+
95
+ function pluginApi() {
96
+ const href = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
97
+ return `${href.replace(/\/$/, '')}/crombolo/api`
98
+ }
99
+
100
+ /** What the server checks a view against, and in which JSX mode; no libs if it will not say. */
101
+ async function typeLibs() {
102
+ try {
103
+ const response = await fetch(`${pluginApi()}/editor-types`, {
104
+ credentials: 'include',
105
+ headers: { accept: 'application/json' },
106
+ })
107
+ if (!response.ok) throw new Error(`the server answered ${response.status}`)
108
+ const body = await response.json()
109
+ return {
110
+ libs: Array.isArray(body?.libs) ? body.libs : [],
111
+ jsxImportSource:
112
+ typeof body?.jsxImportSource === 'string' ? body.jsxImportSource : '@crombolo/crombolo',
113
+ }
114
+ } catch (error) {
115
+ // Said out loud, because the consequence is not obvious: with no types,
116
+ // `@crombolo/crombolo` does not resolve, so there is no `JSX.IntrinsicElements` and
117
+ // TypeScript underlines every tag in the view. Type checking is turned off
118
+ // below rather than left to report that.
119
+ console.error('Crombolo could not fetch the types for the view editor', error)
120
+ return { libs: [], jsxImportSource: '@crombolo/crombolo' }
121
+ }
122
+ }
123
+
124
+ let loaded
125
+
126
+ /**
127
+ * Configures monaco's TypeScript service for TSX, and reports both monaco and
128
+ * whether the dialect is usable.
129
+ *
130
+ * Done on the first view to open rather than at boot, because monaco is several
131
+ * megabytes and most sessions never open one. The types are asked for every time
132
+ * instead of once, so a content type a colleague added is in this editor as
133
+ * soon as the view is reopened; the endpoint tags them, so the browser fetches
134
+ * them again only when they have changed.
135
+ */
136
+ async function configureTsx() {
137
+ let monaco
138
+ try {
139
+ loaded ??= import('@umbraco-cms/backoffice/external/monaco-editor')
140
+ monaco = (await loaded).monaco
141
+ // `monaco.typescript`, not `monaco.languages.typescript`: monaco exports each
142
+ // language service as its own top-level namespace, and the nested form is
143
+ // where older versions kept it. Read both, because getting this wrong is what
144
+ // went wrong the first time — and it went wrong quietly.
145
+ const ts = monaco.typescript ?? monaco.languages?.typescript
146
+ if (!ts) throw new Error('monaco exposes no TypeScript service')
147
+ const { JsxEmit, ModuleKind, ScriptTarget, typescriptDefaults } = ts
148
+ const { libs, jsxImportSource } = await typeLibs()
149
+ // The site's `apps/site/tsconfig.json`, so that what the editor underlines
150
+ // is what `tsc` would report and nothing else — down to the JSX mode the
151
+ // site chose, `@crombolo/crombolo` or `@crombolo/crombolo/loose`.
152
+ typescriptDefaults.setCompilerOptions({
153
+ allowNonTsExtensions: true,
154
+ target: ScriptTarget.ESNext,
155
+ module: ModuleKind.ESNext,
156
+ moduleResolution: BUNDLER,
157
+ jsx: JsxEmit.ReactJSX,
158
+ jsxImportSource,
159
+ allowImportingTsExtensions: true,
160
+ noEmit: true,
161
+ strict: true,
162
+ })
163
+ // Replaced wholesale rather than added one at a time, so a refresh cannot
164
+ // leave a stale copy of the generated types behind.
165
+ typescriptDefaults.setExtraLibs(
166
+ libs.map((lib) => ({ filePath: lib.path, content: lib.content })),
167
+ )
168
+ // Without the types, every name in the view is unresolved and checking it
169
+ // says nothing true. Syntax is still worth checking, and the dialect —
170
+ // highlighting, brackets, JSX that parses — is unaffected either way.
171
+ typescriptDefaults.setDiagnosticsOptions({
172
+ noSemanticValidation: libs.length === 0,
173
+ noSyntaxValidation: false,
174
+ })
175
+ return { monaco, ok: true }
176
+ } catch (error) {
177
+ // Reported rather than swallowed. Configured against the wrong namespace,
178
+ // this failed silently and turned every tag in a view red — and a console
179
+ // error is what the browser suite fails a test on, so the next time this
180
+ // breaks a test says so instead of a person noticing.
181
+ console.error('Crombolo could not configure the TSX dialect for the view editor', error)
182
+ return { monaco, ok: false }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Teaches `umb-code-editor` the dialect: `language="tsx"`, and Umbraco's own
188
+ * hard-coded `razor`, become monaco's `typescript` on a `.tsx` model.
189
+ *
190
+ * `language` is a Lit property, so the accessor to wrap is the one Lit defined
191
+ * on the prototype. If a future version of either defines it elsewhere, the
192
+ * editor keeps Umbraco's Razor rather than this entry point throwing on the way
193
+ * in and taking the TSX scaffolds down with it.
194
+ */
195
+ function teachDialect(editors) {
196
+ const language = Object.getOwnPropertyDescriptor(editors, 'language')
197
+ if (!language?.set) return
198
+ Object.defineProperty(editors, 'language', {
199
+ ...language,
200
+ set(value) {
201
+ const view = value === TSX || value === 'razor'
202
+ if (view) views.add(this)
203
+ else views.delete(this)
204
+ language.set.call(this, view ? 'typescript' : value)
205
+ },
206
+ })
207
+
208
+ const firstUpdated = editors.firstUpdated
209
+ editors.firstUpdated = async function (changed) {
210
+ if (!views.has(this)) return await firstUpdated.call(this, changed)
211
+ const { monaco, ok } = await configureTsx()
212
+ await firstUpdated.call(this, changed)
213
+ const editor = this.editor?.monacoEditor
214
+ const created = editor?.getModel()
215
+ if (!monaco || !editor || !created) return
216
+ if (!ok) {
217
+ // Without the dialect, TypeScript reads a view as a plain `.ts` file and
218
+ // underlines every tag in it. Plain text says less and lies less.
219
+ monaco.editor.setModelLanguage(created, 'plaintext')
220
+ return
221
+ }
222
+ // monaco names the model it creates `inmemory://model/1`, and a name with no
223
+ // `.tsx` on it is a file TypeScript refuses to read JSX in — every tag in
224
+ // the view would be a syntax error. So the model is replaced by one named
225
+ // as the file it is.
226
+ const model = monaco.editor.createModel(
227
+ created.getValue(),
228
+ 'typescript',
229
+ monaco.Uri.parse(`${VIEWS}/view-${++opened}.tsx`),
230
+ )
231
+ editor.setModel(model)
232
+ created.dispose()
233
+ editor.onDidDispose(() => model.dispose())
234
+ }
235
+
236
+ const insert = editors.insert
237
+ editors.insert = function (text) {
238
+ return insert.call(this, views.has(this) ? toTsxSnippet(text) : text)
239
+ }
240
+ }
241
+
242
+ /**
243
+ * The template editor's inserts that cannot become TSX. A partial view is an
244
+ * import as well as a tag, which an insert at the cursor cannot write, and
245
+ * sections are Razor's way for a layout to take content from a page, where a
246
+ * TSX layout takes props.
247
+ */
248
+ async function hideRazorInserts() {
249
+ const menu = (await customElements.whenDefined('umb-templating-insert-menu')).prototype
250
+ Object.defineProperty(menu, 'hidePartialViews', {
251
+ configurable: true,
252
+ get: () => true,
253
+ set: () => {},
254
+ })
255
+
256
+ const editor = (await customElements.whenDefined('umb-template-workspace-editor')).prototype
257
+ const updated = editor.updated
258
+ editor.updated = function (changed) {
259
+ updated?.call(this, changed)
260
+ const sections = this.shadowRoot?.querySelector('#sections-button')
261
+ if (sections) sections.style.display = 'none'
262
+ }
263
+ }
264
+
265
+ /**
266
+ * The value builder's preview shows the snippet it will insert, so it shows the
267
+ * TSX one. Its output is still Umbraco's, and is translated on insert with the rest.
268
+ */
269
+ async function previewFieldsAsTsx() {
270
+ const modal = (await customElements.whenDefined('umb-templating-page-field-builder-modal'))
271
+ .prototype
272
+ const render = modal.render
273
+ modal.render = function () {
274
+ const result = render.call(this)
275
+ if (Array.isArray(result?.values)) result.values = result.values.map(toTsxSnippet)
276
+ return result
277
+ }
278
+ const updated = modal.updated
279
+ modal.updated = function (changed) {
280
+ updated?.call(this, changed)
281
+ const block = this.shadowRoot?.querySelector('umb-code-block')
282
+ if (block && block.getAttribute('language') !== 'TypeScript') {
283
+ block.setAttribute('language', 'TypeScript')
284
+ block.language = 'TypeScript'
285
+ }
286
+ }
287
+ }
288
+
289
+ export const onInit = async () => {
290
+ teachDialect(UmbCodeEditorElement.prototype)
291
+ // Registered lazily, so these wait for the editor to be opened
292
+ hideRazorInserts()
293
+ previewFieldsAsTsx()
294
+
295
+ const repository = UmbTemplateDetailRepository.prototype
296
+ const createScaffold = repository.createScaffold
297
+ repository.createScaffold = async function (preset) {
298
+ const result = await createScaffold.call(this, preset)
299
+ if (result.data) result.data.content = SCAFFOLD
300
+ return result
301
+ }
302
+
303
+ const partialViews = UmbPartialViewDetailRepository.prototype
304
+ const createPartialViewScaffold = partialViews.createScaffold
305
+ partialViews.createScaffold = async function (preset) {
306
+ const result = await createPartialViewScaffold.call(this, preset)
307
+ // Started from a snippet it has content already; empty, it gets the skeleton
308
+ if (result.data && !result.data.content) {
309
+ const { data } = await PartialViewService.getPartialViewSnippetById({ path: { id: 'Empty' } })
310
+ if (data?.content) result.data.content = data.content
311
+ }
312
+ return result
313
+ }
314
+
315
+ // The workspace context is not exported; it is the module beside the package's
316
+ // entry, which the import map resolves to the very module Umbraco loads
317
+ const { UmbTemplateWorkspaceContext } = await import(
318
+ new URL(
319
+ './workspace/template-workspace.context.js',
320
+ import.meta.resolve('@umbraco-cms/backoffice/template'),
321
+ ).href
322
+ )
323
+ const workspace = UmbTemplateWorkspaceContext.prototype
324
+ const setLayoutTemplate = workspace.setLayoutTemplate
325
+ workspace.setLayoutTemplate = async function (unique, updateLayoutBlock) {
326
+ // Umbraco's own update writes Razor; the state it keeps is what we want
327
+ await setLayoutTemplate.call(this, unique, false)
328
+ if (!updateLayoutBlock) return unique
329
+ const items = unique ? (await this.itemRepository.requestItems([unique])).data : []
330
+ const content = this._data.getCurrent()?.content ?? ''
331
+ this._data.updateCurrent({
332
+ content: withLayout(content, items?.[0]?.alias ?? null),
333
+ layoutTemplate: unique ? { unique } : null,
334
+ })
335
+ return unique
336
+ }
337
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The TSX a view gets in place of the Razor Umbraco's template editor inserts.
3
+ *
4
+ * Umbraco's snippet builders (`templating/utils`) are module-private, so they
5
+ * cannot be replaced; what they produce is translated instead, on its way into a
6
+ * view's editor. Each builder has one fixed output shape, matched exactly here,
7
+ * and anything that does not match is returned as it came.
8
+ */
9
+
10
+ const quote = (text) => `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
11
+
12
+ const FALLBACKS = {
13
+ 'Fallback.To(Fallback.Ancestors, Fallback.DefaultValue)': 'ancestors',
14
+ 'Fallback.ToAncestors': 'ancestors',
15
+ 'Fallback.ToDefaultValue': 'defaultValue',
16
+ }
17
+
18
+ const FIELD =
19
+ /^@Model\.Value\("([^"]*)"(?:, fallback: (Fallback\.To\(Fallback\.Ancestors, Fallback\.DefaultValue\)|Fallback\.ToAncestors|Fallback\.ToDefaultValue))?(?:, defaultValue: \(object\)"([\s\S]*)")?\)$/
20
+
21
+ const DICTIONARY = /^@Umbraco\.GetDictionaryValue\("([\s\S]*)"\)$/
22
+
23
+ const QUERY =
24
+ /^\n@\{\n\tvar selection = ([\s\S]*);\n\}\n<ul>\n\t@foreach \(var item in selection\)[\s\S]*<\/ul>\n\n$/
25
+
26
+ /** `@Model.Value(…)` as `{model.text(…)}`, with its fallback and default. */
27
+ function field(match) {
28
+ const [, alias, fallback, defaultValue] = match
29
+ const options = []
30
+ if (fallback) options.push(`fallback: '${FALLBACKS[fallback]}'`)
31
+ if (defaultValue !== undefined) options.push(`default: ${quote(defaultValue)}`)
32
+ const args = options.length ? `${quote(alias)}, { ${options.join(', ')} }` : quote(alias)
33
+ return `{model.text(${args})}`
34
+ }
35
+
36
+ /** The query builder's selection, listed as links the way Umbraco's snippet lists them. */
37
+ function query(match) {
38
+ const chain = match[1].replace(/^const selection = /, '').split('\n')
39
+ return [
40
+ '<ul>',
41
+ ` {${chain[0]}`,
42
+ ...chain.slice(1).map((line) => ` ${line}`),
43
+ ' .map((item) => (',
44
+ ' <li>',
45
+ ' <a href={item.url}>{item.name}</a>',
46
+ ' </li>',
47
+ ' ))}',
48
+ '</ul>',
49
+ '',
50
+ ].join('\n')
51
+ }
52
+
53
+ /** `snippet` as TSX when it is one of Umbraco's Razor snippets; otherwise unchanged. */
54
+ export function toTsxSnippet(snippet) {
55
+ if (typeof snippet !== 'string') return snippet
56
+ const fieldMatch = FIELD.exec(snippet)
57
+ if (fieldMatch) return field(fieldMatch)
58
+ const dictionaryMatch = DICTIONARY.exec(snippet)
59
+ if (dictionaryMatch) return `{dictionary(${quote(dictionaryMatch[1])})}`
60
+ const queryMatch = QUERY.exec(snippet)
61
+ if (queryMatch) return query(queryMatch)
62
+ return snippet
63
+ }