@crombolo/server 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The components the backoffice offers to start from — Umbraco's snippets, with
3
+ * its ids, written as TSX. One is rendered the way any component is:
4
+ * `<Breadcrumb model={model} nav={nav} />`. `Empty` is also the skeleton a new
5
+ * component starts with.
6
+ */
7
+ import type { Snippet } from '@crombolo/api-management'
8
+
9
+ const IMPORTS = "import type { PageProps } from '@crombolo/crombolo'\n\n"
10
+
11
+ export const COMPONENT_SNIPPETS: readonly Snippet[] = [
12
+ {
13
+ id: 'Empty',
14
+ name: 'Empty',
15
+ content: `${IMPORTS}export default function Partial({ model }: Pick<PageProps, 'model'>) {
16
+ return <div>{model.name}</div>
17
+ }
18
+ `,
19
+ },
20
+ {
21
+ id: 'Breadcrumb',
22
+ name: 'Breadcrumb',
23
+ content: `${IMPORTS}export default function Breadcrumb({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
24
+ const trail = [...nav.ancestors(model)].reverse()
25
+ return (
26
+ <ul class="breadcrumb">
27
+ {trail.map((page) => (
28
+ <li>
29
+ <a href={page.url}>{page.name}</a>
30
+ </li>
31
+ ))}
32
+ <li class="active">{model.name}</li>
33
+ </ul>
34
+ )
35
+ }
36
+ `,
37
+ },
38
+ {
39
+ id: 'ListAncestorsFromCurrentPage',
40
+ name: 'List Ancestors From Current Page',
41
+ content: `${IMPORTS}export default function Ancestors({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
42
+ return (
43
+ <ul>
44
+ {nav.ancestors(model).map((page) => (
45
+ <li>
46
+ <a href={page.url}>{page.name}</a>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ )
51
+ }
52
+ `,
53
+ },
54
+ {
55
+ id: 'ListChildPagesFromCurrentPage',
56
+ name: 'List Child Pages From Current Page',
57
+ content: `${IMPORTS}export default function ChildPages({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
58
+ return (
59
+ <ul>
60
+ {nav.children(model).map((page) => (
61
+ <li>
62
+ <a href={page.url}>{page.name}</a>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ )
67
+ }
68
+ `,
69
+ },
70
+ {
71
+ id: 'ListChildPagesOrderedByDate',
72
+ name: 'List Child Pages Ordered By Date',
73
+ content: `${IMPORTS}export default function ChildPagesByDate({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
74
+ const pages = [...nav.children(model)].sort(
75
+ (a, b) => b.createDate.getTime() - a.createDate.getTime(),
76
+ )
77
+ return (
78
+ <ul>
79
+ {pages.map((page) => (
80
+ <li>
81
+ <a href={page.url}>{page.name}</a>
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ )
86
+ }
87
+ `,
88
+ },
89
+ {
90
+ id: 'ListChildPagesOrderedByName',
91
+ name: 'List Child Pages Ordered By Name',
92
+ content: `${IMPORTS}export default function ChildPagesByName({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
93
+ const pages = [...nav.children(model)].sort((a, b) => a.name.localeCompare(b.name))
94
+ return (
95
+ <ul>
96
+ {pages.map((page) => (
97
+ <li>
98
+ <a href={page.url}>{page.name}</a>
99
+ </li>
100
+ ))}
101
+ </ul>
102
+ )
103
+ }
104
+ `,
105
+ },
106
+ {
107
+ id: 'ListDescendantsFromCurrentPage',
108
+ name: 'List Descendants From Current Page',
109
+ content: `import type { PageProps, PublishedContent } from '@crombolo/crombolo'
110
+
111
+ export default function Descendants({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
112
+ const branch = (page: PublishedContent) => (
113
+ <ul>
114
+ {nav.children(page).map((child) => (
115
+ <li>
116
+ <a href={child.url}>{child.name}</a>
117
+ {nav.children(child).length > 0 ? branch(child) : null}
118
+ </li>
119
+ ))}
120
+ </ul>
121
+ )
122
+ return branch(model)
123
+ }
124
+ `,
125
+ },
126
+ {
127
+ id: 'Navigation',
128
+ name: 'Navigation',
129
+ content: `${IMPORTS}export default function Navigation({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
130
+ const site = nav.ancestors(model).at(-1) ?? model
131
+ return (
132
+ <ul class="nav">
133
+ {nav.children(site).map((page) => (
134
+ <li class={page.id === model.id ? 'current' : undefined}>
135
+ <a href={page.url}>{page.name}</a>
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ )
140
+ }
141
+ `,
142
+ },
143
+ {
144
+ id: 'SiteMap',
145
+ name: 'Site Map',
146
+ content: `import type { PageProps, PublishedContent } from '@crombolo/crombolo'
147
+
148
+ export default function SiteMap({ nav }: Pick<PageProps, 'nav'>) {
149
+ const branch = (pages: PublishedContent[]) => (
150
+ <ul>
151
+ {pages.map((page) => (
152
+ <li>
153
+ <a href={page.url}>{page.name}</a>
154
+ {nav.children(page).length > 0 ? branch(nav.children(page)) : null}
155
+ </li>
156
+ ))}
157
+ </ul>
158
+ )
159
+ return <div class="sitemap">{branch(nav.root())}</div>
160
+ }
161
+ `,
162
+ },
163
+ ]
@@ -0,0 +1,266 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join, relative, resolve, sep } from 'node:path'
3
+
4
+ /**
5
+ * A `.tsx` under the site's `components/` directory.
6
+ *
7
+ * There is one root and one kind of file. What makes a component a *template*
8
+ * is that a content type names it — `components = ["pages/homePage"]` — not
9
+ * where it sits, which is why this carries no kind: the schema already records
10
+ * that, and a directory that restated it could disagree with it.
11
+ *
12
+ * (Umbraco splits these into Templates and Partial Views because Razor needed
13
+ * the distinction for master-template inheritance. A TSX layout is an import,
14
+ * so the split bought nothing and cost a rule about where a file may live.)
15
+ */
16
+ export interface ComponentFile {
17
+ /**
18
+ * Its path under the root without the extension, slash-separated — which is
19
+ * exactly the alias a content type names.
20
+ */
21
+ alias: string
22
+ path: string
23
+ }
24
+
25
+ const SOURCE = /\.(tsx|jsx)$/
26
+
27
+ /** An alias that stays inside the root: no climbing, no absolute path, no drive. */
28
+ export function isSafeAlias(alias: string): boolean {
29
+ if (alias.length === 0 || alias.startsWith('/') || alias.includes('\\')) return false
30
+ if (/^[a-zA-Z]:/.test(alias)) return false
31
+ return !alias.split('/').some((part) => part === '' || part === '.' || part === '..')
32
+ }
33
+
34
+ /** Every component on disk, at any depth, in a stable order. */
35
+ export function listComponents(root: string): ComponentFile[] {
36
+ if (!existsSync(root)) return []
37
+ const out: ComponentFile[] = []
38
+ const walk = (dir: string): void => {
39
+ for (const name of readdirSync(dir).sort()) {
40
+ const path = join(dir, name)
41
+ if (statSync(path).isDirectory()) {
42
+ walk(path)
43
+ continue
44
+ }
45
+ if (!SOURCE.test(name)) continue
46
+ out.push({ alias: aliasOf(root, path), path })
47
+ }
48
+ }
49
+ walk(root)
50
+ return out.sort((a, b) => a.alias.localeCompare(b.alias))
51
+ }
52
+
53
+ /** The alias a file answers to: its path under the root, without the extension. */
54
+ export function aliasOf(root: string, path: string): string {
55
+ return relative(root, path).split(sep).join('/').replace(SOURCE, '')
56
+ }
57
+
58
+ export interface ComponentProblem {
59
+ /** Relative to the site, so the message is one somebody can act on. */
60
+ file: string
61
+ message: string
62
+ }
63
+
64
+ export interface ComponentCheckReport {
65
+ components: ComponentFile[]
66
+ /** The aliases a content type names; the rest are only ever imported. */
67
+ declared: readonly string[]
68
+ /** One that does not compile, or a template a type declares and nothing provides. */
69
+ problems: ComponentProblem[]
70
+ /** Whether types were checked, and what the compiler said. */
71
+ types: { checked: boolean; problems: string[]; note?: string }
72
+ }
73
+
74
+ /**
75
+ * Compiles every component, and reports the templates the schema expects.
76
+ *
77
+ * Each file is transpiled first, because that gives the line a syntax error is
78
+ * on, and then imported, because that is what a render does: an import that
79
+ * cannot be resolved is as fatal as a syntax error and shows up nowhere else.
80
+ */
81
+ export async function checkComponents(options: {
82
+ componentsDir: string
83
+ siteDir: string
84
+ /** Component aliases the schema declares, from `allProperties`'s neighbours. */
85
+ declaredComponents?: readonly string[]
86
+ }): Promise<ComponentCheckReport> {
87
+ const components = listComponents(options.componentsDir)
88
+ const problems: ComponentProblem[] = []
89
+ const transpiler = new Bun.Transpiler({ loader: 'tsx' })
90
+
91
+ for (const component of components) {
92
+ const where = relative(options.siteDir, component.path)
93
+ const source = await Bun.file(component.path).text()
94
+ try {
95
+ transpiler.transformSync(source)
96
+ } catch (error) {
97
+ // Bun's message is a bare "Syntax Error"; the line it is on hangs off
98
+ // `position`, and without it the report says nothing anybody can act on.
99
+ const at = (error as { position?: { line?: number; column?: number; lineText?: string } })
100
+ .position
101
+ const where_ = at?.line ? `:${at.line}${at.column === undefined ? '' : `:${at.column}`}` : ''
102
+ const text = at?.lineText?.trim()
103
+ problems.push({
104
+ file: `${where}${where_}`,
105
+ message: `${(error as Error).message.trim()}${text ? ` (${text})` : ''}`,
106
+ })
107
+ continue
108
+ }
109
+ // A relative import that climbs out of the root resolves today and breaks
110
+ // the moment the component is rendered from a snapshot, which is a copy of
111
+ // the tree and nothing above it. Refused here, where the message can say why.
112
+ for (const specifier of transpiler.scanImports(source)) {
113
+ if (!specifier.path.startsWith('.')) continue
114
+ const target = resolve(join(component.path, '..'), specifier.path)
115
+ if (!relative(options.componentsDir, target).startsWith('..')) continue
116
+ problems.push({
117
+ file: where,
118
+ message:
119
+ `imports "${specifier.path}", which is outside components/. A component is rendered ` +
120
+ 'from a snapshot of the tree, so it can only import what the tree contains.',
121
+ })
122
+ }
123
+
124
+ try {
125
+ // A query does not bust Bun's module cache, so a path imported once in
126
+ // this process answers from the registry. Harmless: the CLI checks each
127
+ // component once and exits, and each test uses a directory of its own.
128
+ await import(component.path)
129
+ } catch (error) {
130
+ problems.push({ file: where, message: (error as Error).message.trim() })
131
+ }
132
+ }
133
+
134
+ const present = new Set(components.map((component) => component.alias))
135
+ const declared = [...new Set(options.declaredComponents ?? [])]
136
+ for (const alias of declared) {
137
+ if (!isSafeAlias(alias)) {
138
+ problems.push({
139
+ file: relative(options.siteDir, options.componentsDir),
140
+ message: `a content type declares the component "${alias}", which is not a path inside components/`,
141
+ })
142
+ continue
143
+ }
144
+ if (present.has(alias)) continue
145
+ problems.push({
146
+ file: relative(options.siteDir, componentFileFor(options.componentsDir, alias)),
147
+ message: `a content type declares the component "${alias}", and there is no file for it`,
148
+ })
149
+ }
150
+
151
+ return {
152
+ components,
153
+ declared: declared.filter((alias) => present.has(alias)),
154
+ problems,
155
+ types: await checkTypes(options.siteDir),
156
+ }
157
+ }
158
+
159
+ /**
160
+ * `tsc --noEmit` with the site's own tsconfig, when the site has the compiler.
161
+ *
162
+ * `typescript` is not a dependency of crombolo — a site that has not installed
163
+ * it is told so rather than being made to carry one.
164
+ */
165
+ async function checkTypes(siteDir: string): Promise<ComponentCheckReport['types']> {
166
+ const tsc = join(siteDir, 'node_modules', 'typescript', 'bin', 'tsc')
167
+ if (!existsSync(tsc))
168
+ return {
169
+ checked: false,
170
+ problems: [],
171
+ note: 'types were not checked: `bun add -d typescript` to check them too',
172
+ }
173
+ if (!existsSync(join(siteDir, 'tsconfig.json')))
174
+ return { checked: false, problems: [], note: 'types were not checked: no tsconfig.json here' }
175
+
176
+ const proc = Bun.spawn(['bun', tsc, '--noEmit', '-p', siteDir], {
177
+ cwd: siteDir,
178
+ stdout: 'pipe',
179
+ stderr: 'pipe',
180
+ })
181
+ const [out, err] = await Promise.all([
182
+ new Response(proc.stdout).text(),
183
+ new Response(proc.stderr).text(),
184
+ ])
185
+ await proc.exited
186
+ const lines = `${out}${err}`
187
+ .split('\n')
188
+ .map((line) => line.trim())
189
+ .filter((line) => line.length > 0 && !/^Found \d+ error/.test(line))
190
+ return { checked: true, problems: lines }
191
+ }
192
+
193
+ /** Where a component with this alias lives. The alias is the path. */
194
+ export function componentFileFor(root: string, alias: string): string {
195
+ if (!isSafeAlias(alias)) throw new Error(`"${alias}" is not a path inside components/`)
196
+ return join(root, `${alias}.tsx`)
197
+ }
198
+
199
+ /** What a component looks like: a function, taking whatever imports it hands it. */
200
+ export function componentScaffold(alias: string): string {
201
+ const name = componentName(alias)
202
+ return `import type { PublishedContent } from '@crombolo/crombolo'
203
+
204
+ export function ${name}({ model }: { model: PublishedContent }) {
205
+ return <p>{model.name}</p>
206
+ }
207
+ `
208
+ }
209
+
210
+ /** A PascalCase identifier from an alias, which may be a path. */
211
+ export function componentName(alias: string): string {
212
+ const base = alias.split('/').pop() ?? alias
213
+ const cleaned = base.replace(/[^A-Za-z0-9]+(.)?/g, (_, next: string | undefined) =>
214
+ next ? next.toUpperCase() : '',
215
+ )
216
+ const name = cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
217
+ return /^[A-Za-z]/.test(name) ? name : `Component${name}`
218
+ }
219
+
220
+ export type AssetKind = 'stylesheet' | 'script'
221
+
222
+ export interface AssetFile {
223
+ kind: AssetKind
224
+ name: string
225
+ path: string
226
+ }
227
+
228
+ /** Umbraco's places, which the backoffice's own editors read and write. */
229
+ export function assetDirs(config: {
230
+ stylesheetsDir: string
231
+ scriptsDir: string
232
+ }): Record<AssetKind, { dir: string; extension: string }> {
233
+ return {
234
+ stylesheet: { dir: config.stylesheetsDir, extension: '.css' },
235
+ script: { dir: config.scriptsDir, extension: '.js' },
236
+ }
237
+ }
238
+
239
+ export function listAssets(config: { stylesheetsDir: string; scriptsDir: string }): AssetFile[] {
240
+ const out: AssetFile[] = []
241
+ for (const [kind, where] of Object.entries(assetDirs(config)) as Array<
242
+ [AssetKind, { dir: string; extension: string }]
243
+ >) {
244
+ if (!existsSync(where.dir)) continue
245
+ const walk = (dir: string): void => {
246
+ for (const name of readdirSync(dir).sort()) {
247
+ const path = join(dir, name)
248
+ if (statSync(path).isDirectory()) {
249
+ walk(path)
250
+ continue
251
+ }
252
+ if (name.endsWith(where.extension))
253
+ out.push({ kind, name: relative(where.dir, path), path })
254
+ }
255
+ }
256
+ walk(where.dir)
257
+ }
258
+ return out
259
+ }
260
+
261
+ /** The file `assets new` writes: enough to be valid and obviously a starting point. */
262
+ export function assetScaffold(kind: AssetKind, name: string): string {
263
+ return kind === 'stylesheet'
264
+ ? `/* ${name} — linked from a view with <link rel="stylesheet" href="/css/${name}"> */\n`
265
+ : `// ${name} — loaded by a view with <script src="/scripts/${name}"></script>\n`
266
+ }