@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
package/src/domains.ts ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * `domains.toml`: which hostnames reach which page, and in which culture.
3
+ *
4
+ * Hostnames are the one piece of site metadata that differs in every
5
+ * environment, so they are neither content nor schema. They are a file of their
6
+ * own at the site root — data, not code, so `crombolo domains set` can write it
7
+ * — with `${VAR}` read from the environment at sync time, which is how one
8
+ * committed file serves development, staging and production.
9
+ *
10
+ * The file is the truth. Boot converges the `domain` table onto it, so deleting
11
+ * an entry takes effect, exactly as `syncConfiguredRedirects` does for rules in
12
+ * the config. A site with no `domains.toml` is left alone entirely: nothing to
13
+ * converge onto means the backoffice stays the only way, as it was before.
14
+ */
15
+ import { existsSync, readFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { ObjectTypes } from '@crombolo/core'
18
+ import {
19
+ type Db,
20
+ DocumentRepository,
21
+ DomainRepository,
22
+ describeRefFailure,
23
+ LanguageRepository,
24
+ NodeRepository,
25
+ type NodeRow,
26
+ resolveNodeRef,
27
+ } from '@crombolo/data'
28
+
29
+ export const DOMAINS_FILE = 'domains.toml'
30
+
31
+ /** One hostname bound to one node, as the file declares it. */
32
+ export interface DomainDeclaration {
33
+ /** A content path (`/`, `/fr`) or a node uuid; resolved when the file is applied. */
34
+ node: string
35
+ /** A hostname, optionally with a port and a path, and optionally `${VAR}`. */
36
+ host: string
37
+ /** The culture this hostname serves; absent means the site's default. */
38
+ culture?: string
39
+ /** The culture the node itself falls back to, with or without a hostname. */
40
+ defaultCulture?: string
41
+ }
42
+
43
+ export interface DomainsFileResult {
44
+ declarations: DomainDeclaration[]
45
+ problems: string[]
46
+ }
47
+
48
+ const str = (value: unknown): string | undefined =>
49
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
50
+
51
+ export function parseDomainsFile(source: string): DomainsFileResult {
52
+ const problems: string[] = []
53
+ let raw: Record<string, unknown>
54
+ try {
55
+ raw = Bun.TOML.parse(source) as Record<string, unknown>
56
+ } catch (error) {
57
+ return { declarations: [], problems: [`invalid TOML: ${(error as Error).message}`] }
58
+ }
59
+ const entries = Array.isArray(raw.domain) ? (raw.domain as Record<string, unknown>[]) : []
60
+ if (raw.domain !== undefined && entries.length === 0)
61
+ problems.push('`domain` must be a list of `[[domain]]` entries')
62
+
63
+ const declarations: DomainDeclaration[] = []
64
+ for (const [index, entry] of entries.entries()) {
65
+ const where = `domain[${index}]`
66
+ const node = str(entry.node)
67
+ const host = str(entry.host)
68
+ const culture = str(entry.culture)
69
+ const defaultCulture = str(entry['default-culture'])
70
+ if (!node) {
71
+ problems.push(`${where}: no \`node\``)
72
+ continue
73
+ }
74
+ // An entry may carry only a default culture: a node can vary by culture
75
+ // without a hostname of its own, which is what Umbraco's `*<id>` row means.
76
+ if (!host && !defaultCulture) {
77
+ problems.push(`${where}: needs a \`host\`, a \`default-culture\`, or both`)
78
+ continue
79
+ }
80
+ declarations.push({ node, host: host ?? '', culture, defaultCulture })
81
+ }
82
+ return { declarations, problems }
83
+ }
84
+
85
+ /** Model → canonical TOML, so a rewrite by the CLI reads as a hand-written file. */
86
+ export function writeDomainsFile(declarations: readonly DomainDeclaration[]): string {
87
+ const quote = (value: string) => JSON.stringify(value)
88
+ const lines: string[] = [
89
+ '# Which hostnames reach which page, and in which culture.',
90
+ `# \`\${VAR}\` is read from the environment when this is applied, so one file`,
91
+ '# serves every environment. `crombolo domains` prints what is in force.',
92
+ ]
93
+ for (const declaration of declarations) {
94
+ lines.push('', '[[domain]]', `node = ${quote(declaration.node)}`)
95
+ if (declaration.host) lines.push(`host = ${quote(declaration.host)}`)
96
+ if (declaration.culture) lines.push(`culture = ${quote(declaration.culture)}`)
97
+ if (declaration.defaultCulture)
98
+ lines.push(`default-culture = ${quote(declaration.defaultCulture)}`)
99
+ }
100
+ return `${lines.join('\n')}\n`
101
+ }
102
+
103
+ const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g
104
+
105
+ /** `${VAR}` from the environment; an unset one is reported rather than guessed. */
106
+ export function resolvePlaceholders(
107
+ value: string,
108
+ env: Record<string, string | undefined> = Bun.env,
109
+ ): { value: string; missing: string[] } {
110
+ const missing: string[] = []
111
+ const resolved = value.replace(PLACEHOLDER, (_, name: string) => {
112
+ const found = env[name]
113
+ if (found === undefined || found === '') {
114
+ missing.push(name)
115
+ return ''
116
+ }
117
+ return found
118
+ })
119
+ return { value: resolved, missing }
120
+ }
121
+
122
+ export interface DomainsSyncReport {
123
+ /** No `domains.toml`: nothing was read and nothing was changed. */
124
+ action: 'absent' | 'applied'
125
+ /** Hostnames now bound, after interpolation. */
126
+ applied: Array<{ node: string; host: string; culture?: string }>
127
+ /** Bindings removed because the file no longer declares them. */
128
+ removed: number
129
+ /** Anything that stopped one entry being applied; the rest still are. */
130
+ problems: string[]
131
+ }
132
+
133
+ export function domainsFilePath(siteDir: string): string {
134
+ return join(siteDir, DOMAINS_FILE)
135
+ }
136
+
137
+ /** What `domains undo` restores: the file as it was before the last write. */
138
+ export const DOMAINS_BACKUP_FILE = `${DOMAINS_FILE}.bak`
139
+
140
+ /**
141
+ * Writes the file, keeping the one it replaces.
142
+ *
143
+ * A wrong hostname takes a site off the air — once a root page has one, that is
144
+ * the only name it answers on — so every write leaves the previous text beside
145
+ * it and `undoDomainsFile` puts it back. On a production node the backup is as
146
+ * ephemeral as the file itself, which is why the change is worth committing.
147
+ */
148
+ export async function writeDomains(
149
+ siteDir: string,
150
+ declarations: readonly DomainDeclaration[],
151
+ ): Promise<{ file: string; backup: string | undefined }> {
152
+ const file = domainsFilePath(siteDir)
153
+ const backup = join(siteDir, DOMAINS_BACKUP_FILE)
154
+ const kept = existsSync(file) ? readFileSync(file, 'utf8') : undefined
155
+ if (kept !== undefined) await Bun.write(backup, kept)
156
+ await Bun.write(file, writeDomainsFile(declarations))
157
+ return { file, backup: kept === undefined ? undefined : backup }
158
+ }
159
+
160
+ /** Puts back what the last write replaced; the current file becomes the backup. */
161
+ export async function undoDomainsFile(
162
+ siteDir: string,
163
+ ): Promise<{ ok: boolean; file: string; message?: string }> {
164
+ const file = domainsFilePath(siteDir)
165
+ const backup = join(siteDir, DOMAINS_BACKUP_FILE)
166
+ if (!existsSync(backup))
167
+ return { ok: false, file, message: `there is no ${DOMAINS_BACKUP_FILE} to go back to` }
168
+ const previous = readFileSync(backup, 'utf8')
169
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : undefined
170
+ await Bun.write(file, previous)
171
+ // So that undo is itself undoable, which is what somebody who has just undone
172
+ // the wrong thing needs.
173
+ await Bun.write(backup, current ?? '')
174
+ return { ok: true, file }
175
+ }
176
+
177
+ /**
178
+ * What to call a page in the file: whatever it is already called there, so a
179
+ * readable path written by a person stays a path, and its uuid when the file
180
+ * has not mentioned it before.
181
+ */
182
+ export async function nodeReferenceFor(
183
+ db: Db,
184
+ declarations: readonly DomainDeclaration[],
185
+ node: NodeRow,
186
+ ): Promise<string> {
187
+ for (const name of new Set(declarations.map((declaration) => declaration.node))) {
188
+ const resolved = await resolveDomainNode(db, name)
189
+ if (resolved.ok && resolved.node.id === node.id) return name
190
+ }
191
+ return node.key
192
+ }
193
+
194
+ /**
195
+ * Every entry except the ones that belong to this page.
196
+ *
197
+ * Which they are is decided by resolving each entry, not by matching the text:
198
+ * `/` and `/Home` can be the same page, so clearing one by the name the file
199
+ * did not use would otherwise leave it bound.
200
+ */
201
+ export async function withoutNode(
202
+ db: Db,
203
+ declarations: readonly DomainDeclaration[],
204
+ node: NodeRow,
205
+ ): Promise<DomainDeclaration[]> {
206
+ const kept: DomainDeclaration[] = []
207
+ for (const declaration of declarations) {
208
+ const owner = await resolveDomainNode(db, declaration.node)
209
+ if (owner.ok && owner.node.id === node.id) continue
210
+ kept.push(declaration)
211
+ }
212
+ return kept
213
+ }
214
+
215
+ /**
216
+ * Replaces one page's entries, leaving every other entry exactly as written.
217
+ *
218
+ * The file is rewritten from the model, not from the database: regenerating it
219
+ * wholesale would resolve `${VAR}` in other entries into whatever this machine
220
+ * happens to have, baking one environment's hostnames into a file that serves
221
+ * all of them.
222
+ */
223
+ export function replaceNodeDeclarations(
224
+ existing: readonly DomainDeclaration[],
225
+ node: string,
226
+ hosts: ReadonlyArray<{ host: string; culture?: string }>,
227
+ defaultCulture?: string,
228
+ ): DomainDeclaration[] {
229
+ const others = existing.filter((declaration) => declaration.node !== node)
230
+ if (hosts.length === 0 && !defaultCulture) return others
231
+ if (hosts.length === 0) return [...others, { node, host: '', defaultCulture }]
232
+ return [
233
+ ...others,
234
+ ...hosts.map((entry, index) => ({
235
+ node,
236
+ host: entry.host,
237
+ culture: entry.culture,
238
+ defaultCulture: index === 0 ? defaultCulture : undefined,
239
+ })),
240
+ ]
241
+ }
242
+
243
+ /**
244
+ * The page a declaration names: a path, a uuid, or `/` for the root page.
245
+ *
246
+ * `/` is what a visitor types, and on a site with one root it is unambiguous —
247
+ * but a hostname binds to a *node*, so with several roots it is refused with
248
+ * their names rather than guessed at.
249
+ */
250
+ export async function resolveDomainNode(
251
+ db: Db,
252
+ ref: string,
253
+ ): Promise<{ ok: true; node: NodeRow } | { ok: false; message: string }> {
254
+ if (ref.trim() === '/') {
255
+ const roots = (await new DocumentRepository(db).children(null, 0, 50)).items
256
+ const first = roots[0]
257
+ if (!first) return { ok: false, message: 'this site has no pages at the root' }
258
+ if (roots.length > 1)
259
+ return {
260
+ ok: false,
261
+ message: `"/" matches ${roots.length} root pages (${roots.map((r) => r.text).join(', ')}) — name one`,
262
+ }
263
+ return { ok: true, node: first }
264
+ }
265
+ const resolved = await resolveNodeRef(db, [ObjectTypes.Document], ref)
266
+ return resolved.ok
267
+ ? { ok: true, node: resolved.node }
268
+ : { ok: false, message: describeRefFailure(ref, resolved) }
269
+ }
270
+
271
+ export function readDomainsFile(siteDir: string): DomainsFileResult | undefined {
272
+ const file = domainsFilePath(siteDir)
273
+ return existsSync(file) ? parseDomainsFile(readFileSync(file, 'utf8')) : undefined
274
+ }
275
+
276
+ /**
277
+ * Converges the `domain` table onto the file.
278
+ *
279
+ * Entries are grouped by node because an assignment is replaced per node, as
280
+ * the backoffice's dialog replaces it: stating a node's hostnames says what
281
+ * they are. Nodes the file does not name keep nothing — the file is the truth,
282
+ * so a binding it dropped goes — but a site with no file at all is untouched.
283
+ */
284
+ export async function syncDomainsFile(
285
+ db: Db,
286
+ siteDir: string,
287
+ env: Record<string, string | undefined> = Bun.env,
288
+ ): Promise<DomainsSyncReport> {
289
+ const read = readDomainsFile(siteDir)
290
+ if (!read) return { action: 'absent', applied: [], removed: 0, problems: [] }
291
+
292
+ const problems = [...read.problems]
293
+ const applied: DomainsSyncReport['applied'] = []
294
+ const domains = new DomainRepository(db)
295
+ const nodes = new NodeRepository(db)
296
+ // A hostname with no culture of its own serves the site's default one, which
297
+ // is what the dialog means by leaving the language unset — the repository
298
+ // wants an actual language, so it is resolved here rather than sent empty.
299
+ const languages = await new LanguageRepository(db).all()
300
+ const defaultCulture = languages.find((language) => language.isDefault)?.isoCode ?? ''
301
+
302
+ const byNode = new Map<string, DomainDeclaration[]>()
303
+ for (const declaration of read.declarations) {
304
+ const group = byNode.get(declaration.node)
305
+ if (group) group.push(declaration)
306
+ else byNode.set(declaration.node, [declaration])
307
+ }
308
+
309
+ const kept = new Set<number>()
310
+ const namedBy = new Map<number, string>()
311
+ for (const [node, declarations] of byNode) {
312
+ const resolved = await resolveDomainNode(db, node)
313
+ if (!resolved.ok) {
314
+ problems.push(resolved.message)
315
+ continue
316
+ }
317
+ // `/` and `/Home` can be the same page. Each group replaces that page's
318
+ // whole assignment, so without this the later one silently wins and the
319
+ // earlier one's hostnames are gone with nothing having said so.
320
+ const already = namedBy.get(resolved.node.id)
321
+ if (already !== undefined) {
322
+ problems.push(`${node}: the same page as "${already}" — name it once`)
323
+ continue
324
+ }
325
+ namedBy.set(resolved.node.id, node)
326
+ const hosts: Array<{ domainName: string; isoCode: string }> = []
327
+ const wanted: DomainsSyncReport['applied'] = []
328
+ let defaultIsoCode: string | null = null
329
+ for (const declaration of declarations) {
330
+ if (declaration.defaultCulture) defaultIsoCode = declaration.defaultCulture
331
+ if (!declaration.host) continue
332
+ const host = resolvePlaceholders(declaration.host, env)
333
+ if (host.missing.length > 0) {
334
+ // Skipped rather than bound to a half-interpolated name: a hostname
335
+ // built from an unset variable would answer for nobody and hide the
336
+ // mistake. The site still starts; the problem is reported.
337
+ problems.push(
338
+ `${node}: ${declaration.host} is not applied — ${host.missing.join(', ')} ${host.missing.length === 1 ? 'is' : 'are'} not set in this environment`,
339
+ )
340
+ continue
341
+ }
342
+ hosts.push({ domainName: host.value, isoCode: declaration.culture ?? defaultCulture })
343
+ wanted.push({ node, host: host.value, culture: declaration.culture })
344
+ }
345
+
346
+ const result = await domains.setForNode(resolved.node.id, { defaultIsoCode, domains: hosts })
347
+ if (result.status !== 'ok') {
348
+ problems.push(`${node}: ${describeSetFailure(result)}`)
349
+ continue
350
+ }
351
+ // Only once it is in the database: reporting a binding that was refused is
352
+ // how a hostname that is not serving anything looks like one that is.
353
+ applied.push(...wanted)
354
+ kept.add(resolved.node.id)
355
+ }
356
+
357
+ // Anything bound to a node the file no longer names. `setForNode` already
358
+ // replaced the assignments of the nodes it does name.
359
+ let removed = 0
360
+ const nodeIds = new Set((await domains.all()).map((row) => row.nodeId))
361
+ for (const nodeId of nodeIds) {
362
+ if (kept.has(nodeId)) continue
363
+ const node = await nodes.byId(nodeId)
364
+ if (!node) continue
365
+ const before = await domains.forNode(nodeId)
366
+ await domains.setForNode(nodeId, { defaultIsoCode: null, domains: [] })
367
+ removed += before.domains.length + (before.defaultIsoCode ? 1 : 0)
368
+ }
369
+
370
+ return { action: 'applied', applied, removed, problems }
371
+ }
372
+
373
+ function describeSetFailure(result: { status: string; domainName?: string; isoCode?: string }) {
374
+ switch (result.status) {
375
+ case 'invalidLanguage':
376
+ return `no language "${result.isoCode}" is configured here`
377
+ case 'invalidName':
378
+ return `"${result.domainName}" is not a valid hostname`
379
+ case 'conflict':
380
+ return `"${result.domainName}" is already bound to another page`
381
+ default:
382
+ return result.status
383
+ }
384
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The declarations the backoffice's view editor loads into monaco, so a view is
3
+ * checked in the editor against the same types `tsc` checks it against on disk.
4
+ *
5
+ * Nothing here is generated from the types: `@crombolo/render` ships its own
6
+ * sources, and those sources are what `@crombolo/crombolo` re-exports to a view, so they
7
+ * are served as they are and cannot drift — with React's DOM types and the CSS
8
+ * types under them, which the sources import for each tag's attributes. The one
9
+ * generated file is the schema's `content-types.d.ts`, read fresh on every
10
+ * request so a content type added a minute ago is already in the editor.
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { readdir, readFile } from 'node:fs/promises'
14
+ import { createRequire } from 'node:module'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { intendedTypes } from './models-builder.ts'
18
+
19
+ /** A file in the editor's virtual file system. `path` is a URL, not a disk path. */
20
+ export interface EditorTypeLib {
21
+ path: string
22
+ content: string
23
+ }
24
+
25
+ /**
26
+ * Where `import … from '@crombolo/crombolo'` lands, by TypeScript's own directory-index
27
+ * lookup: no import map and no `paths` entry, because the editor's compiler
28
+ * options have neither a base URL nor a working directory to resolve against.
29
+ */
30
+ const MODULE_DIR = 'file:///node_modules/@crombolo/crombolo'
31
+
32
+ /**
33
+ * The generated types, at the depth they sit on disk. A view is opened under
34
+ * `components/`, so `../schema/content-types.d.ts` resolves in the editor exactly as
35
+ * it does in the site.
36
+ */
37
+ export const EDITOR_CONTENT_TYPES = 'file:///schema/content-types.d.ts'
38
+
39
+ /** Where the render sources' `import type … from 'react'` lands, by TypeScript's `@types` lookup. */
40
+ const REACT_TYPES_DIR = 'file:///node_modules/@types/react'
41
+ /** Where React's own `import … from 'csstype'` lands. */
42
+ const CSSTYPE_DIR = 'file:///node_modules/csstype'
43
+
44
+ /** The JSX modes a site may choose, by the `jsxImportSource` that selects each. */
45
+ export const JSX_IMPORT_SOURCES = ['@crombolo/crombolo', '@crombolo/crombolo/loose'] as const
46
+ export type JsxImportSource = (typeof JSX_IMPORT_SOURCES)[number]
47
+
48
+ let cached: Promise<EditorTypeLib[]> | undefined
49
+
50
+ /** `@crombolo/render`'s sources, read from the installed package, and the type packages they import. */
51
+ function moduleLibs(): Promise<EditorTypeLib[]> {
52
+ cached ??= (async () => {
53
+ const dir = dirname(fileURLToPath(import.meta.resolve('@crombolo/render')))
54
+ const sources = async (folder: string) =>
55
+ Promise.all(
56
+ (await readdir(join(dir, folder)))
57
+ .filter((name) => name.endsWith('.ts'))
58
+ .sort()
59
+ .map(async (name) => ({
60
+ path: `${MODULE_DIR}/${folder ? `${folder}/` : ''}${name}`,
61
+ content: await readFile(join(dir, folder, name), 'utf8'),
62
+ })),
63
+ )
64
+ // Resolved from the render package, as its own `import type` resolves them.
65
+ const reactDir = dirname(
66
+ createRequire(join(dir, 'index.ts')).resolve('@types/react/package.json'),
67
+ )
68
+ const cssDir = dirname(
69
+ createRequire(join(reactDir, 'index.d.ts')).resolve('csstype/package.json'),
70
+ )
71
+ const declaration = async (from: string, name: string, to: string) => ({
72
+ path: `${to}/${name}`,
73
+ content: await readFile(join(from, name), 'utf8'),
74
+ })
75
+ return [
76
+ ...(await sources('')),
77
+ ...(await sources('loose')),
78
+ await declaration(reactDir, 'index.d.ts', REACT_TYPES_DIR),
79
+ await declaration(reactDir, 'global.d.ts', REACT_TYPES_DIR),
80
+ await declaration(cssDir, 'index.d.ts', CSSTYPE_DIR),
81
+ ]
82
+ })()
83
+ return cached
84
+ }
85
+
86
+ /**
87
+ * The JSX mode the site's `tsconfig.json` chooses, so the editor checks a view
88
+ * as `tsc` would. Read leniently, since a tsconfig may carry comments; anything
89
+ * other than a mode this package serves is the default.
90
+ */
91
+ export function siteJsxImportSource(siteDir: string): JsxImportSource {
92
+ const file = join(siteDir, 'tsconfig.json')
93
+ if (!existsSync(file)) return '@crombolo/crombolo'
94
+ const found = /"jsxImportSource"\s*:\s*"([^"]+)"/.exec(readFileSync(file, 'utf8'))?.[1]
95
+ return (JSX_IMPORT_SOURCES as readonly string[]).includes(found ?? '')
96
+ ? (found as JsxImportSource)
97
+ : '@crombolo/crombolo'
98
+ }
99
+
100
+ export async function editorTypeLibs(options: { schemaDir: string }): Promise<EditorTypeLib[]> {
101
+ const libs = [...(await moduleLibs())]
102
+ const intended = intendedTypes(options.schemaDir)
103
+ // Left out rather than stubbed when the schema does not generate: a view that
104
+ // imports it then says the file is missing, which is true, instead of being
105
+ // checked against types that silently have nothing in them.
106
+ if ('types' in intended) libs.push({ path: EDITOR_CONTENT_TYPES, content: intended.types })
107
+ return libs
108
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * E-mail through an endpoint the site owns.
3
+ *
4
+ * The escape hatch, and the answer for providers whose API cannot be reached
5
+ * with a key alone — SES wants a SigV4 signature, and signing by hand is code
6
+ * that cannot be honestly verified without an AWS account. A small function in
7
+ * front of it, or any internal mail relay with an HTTP face, works here instead.
8
+ *
9
+ * The body is this CMS's own shape rather than any provider's, because the
10
+ * endpoint on the other end is being written for it:
11
+ *
12
+ * { from, to: [{email, name}], cc, bcc, replyTo, subject, text, html,
13
+ * attachments: [{filename, contentType, content}] } // content is base64
14
+ *
15
+ * Any 2xx is a success. A JSON body with an `id` is recorded, and no body is
16
+ * fine.
17
+ */
18
+ import {
19
+ assertSecureEndpoint,
20
+ base64,
21
+ type EmailAdapterOptions,
22
+ type EmailPort,
23
+ postToProvider,
24
+ validateMessage,
25
+ } from './email.ts'
26
+
27
+ export interface CustomEmailOptions extends EmailAdapterOptions {
28
+ url: string
29
+ /** Sent as `authorization: Bearer …` when set. */
30
+ apiKey?: string
31
+ /** Anything else the endpoint needs. */
32
+ headers?: Record<string, string>
33
+ }
34
+
35
+ export function customEmail(options: CustomEmailOptions): EmailPort {
36
+ if (!options.url) throw new Error('A custom e-mail adapter needs a URL.')
37
+ assertSecureEndpoint(options.url, 'custom')
38
+ const call = options.fetch ?? fetch
39
+
40
+ return {
41
+ description: `custom (${options.url})`,
42
+ provider: 'custom',
43
+
44
+ async send(message) {
45
+ const from = message.from ?? options.from
46
+ const invalid = validateMessage(message, from)
47
+ if (invalid) return { ok: false, error: invalid }
48
+
49
+ return postToProvider(
50
+ call,
51
+ options.url,
52
+ {
53
+ method: 'POST',
54
+ headers: {
55
+ 'content-type': 'application/json',
56
+ ...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
57
+ ...options.headers,
58
+ },
59
+ body: JSON.stringify({
60
+ from,
61
+ to: message.to,
62
+ cc: message.cc,
63
+ bcc: message.bcc,
64
+ replyTo: message.replyTo,
65
+ subject: message.subject,
66
+ text: message.text,
67
+ html: message.html,
68
+ attachments: message.attachments?.map((attachment) => ({
69
+ filename: attachment.filename,
70
+ contentType: attachment.contentType,
71
+ content: base64(attachment.bytes),
72
+ })),
73
+ }),
74
+ },
75
+ (body) => (body as { id?: string }).id ?? null,
76
+ )
77
+ },
78
+ }
79
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What a site cannot do because it has no e-mail, said once at boot.
3
+ *
4
+ * The alternative is an operator finding out when an invitation never arrives,
5
+ * which is the worst moment and the hardest to diagnose: nothing errored.
6
+ */
7
+ import type { EmailAvailability } from './email.ts'
8
+
9
+ export interface EmailNoticeLog {
10
+ info(message: string, properties?: Record<string, unknown>): void
11
+ warning(message: string, properties?: Record<string, unknown>): void
12
+ }
13
+
14
+ export function noticeAboutEmail(
15
+ availability: EmailAvailability,
16
+ log: EmailNoticeLog,
17
+ /** Whether anything would actually be blocked; a developer is told, not warned. */
18
+ options: { development?: boolean } = {},
19
+ ): void {
20
+ if (availability.available) {
21
+ log.info('E-mail is sent through {provider}.', { provider: availability.description })
22
+ return
23
+ }
24
+ // The console stand-in is development's expected state, and it prints every
25
+ // message it is given. Announcing it on each boot is noise in the one place
26
+ // the noise is loudest.
27
+ if (availability.provider === 'log') return
28
+ const message = 'No e-mail provider is configured, so {affected} unavailable. {hint}'
29
+ const properties = {
30
+ affected: `${availability.affects.join(', ')} ${availability.affects.length === 1 ? 'is' : 'are'}`,
31
+ hint: availability.reason,
32
+ }
33
+ // In development this is the expected state and not worth a warning.
34
+ if (options.development) log.info(message, properties)
35
+ else log.warning(message, properties)
36
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * E-mail through Postmark's HTTPS API.
3
+ *
4
+ * Postmark separates transactional and broadcast traffic into message streams
5
+ * and rejects a send to the wrong one, so `messageStream` is configurable and
6
+ * defaults to the transactional stream every account has.
7
+ */
8
+ import {
9
+ assertSecureEndpoint,
10
+ base64,
11
+ type EmailAdapterOptions,
12
+ type EmailPort,
13
+ formatAddress,
14
+ postToProvider,
15
+ validateMessage,
16
+ } from './email.ts'
17
+
18
+ const ENDPOINT = 'https://api.postmarkapp.com/email'
19
+
20
+ export interface PostmarkOptions extends EmailAdapterOptions {
21
+ /** Postmark calls it a server token; it is per-server, not per-account. */
22
+ apiKey: string
23
+ messageStream?: string
24
+ endpoint?: string
25
+ }
26
+
27
+ export function postmarkEmail(options: PostmarkOptions): EmailPort {
28
+ if (!options.apiKey) throw new Error('A Postmark e-mail adapter needs a server token.')
29
+ const call = options.fetch ?? fetch
30
+ const endpoint = options.endpoint ?? ENDPOINT
31
+ assertSecureEndpoint(endpoint, 'Postmark')
32
+ const stream = options.messageStream || 'outbound'
33
+
34
+ return {
35
+ description: stream === 'outbound' ? 'postmark' : `postmark (${stream})`,
36
+ provider: 'postmark',
37
+
38
+ async send(message) {
39
+ const from = message.from ?? options.from
40
+ const invalid = validateMessage(message, from)
41
+ if (invalid) return { ok: false, error: invalid }
42
+
43
+ // Postmark takes recipients as one comma-separated header value, not a list.
44
+ const list = (addresses: typeof message.to | undefined) =>
45
+ addresses && addresses.length > 0 ? addresses.map(formatAddress).join(', ') : undefined
46
+
47
+ return postToProvider(
48
+ call,
49
+ endpoint,
50
+ {
51
+ method: 'POST',
52
+ headers: {
53
+ 'x-postmark-server-token': options.apiKey,
54
+ 'content-type': 'application/json',
55
+ accept: 'application/json',
56
+ },
57
+ body: JSON.stringify({
58
+ From: formatAddress(from),
59
+ To: list(message.to),
60
+ Cc: list(message.cc),
61
+ Bcc: list(message.bcc),
62
+ ReplyTo: message.replyTo ? formatAddress(message.replyTo) : undefined,
63
+ Subject: message.subject,
64
+ TextBody: message.text,
65
+ HtmlBody: message.html,
66
+ MessageStream: stream,
67
+ Attachments: message.attachments?.map((attachment) => ({
68
+ Name: attachment.filename,
69
+ Content: base64(attachment.bytes),
70
+ ContentType: attachment.contentType,
71
+ })),
72
+ }),
73
+ },
74
+ (body) => (body as { MessageID?: string }).MessageID ?? null,
75
+ )
76
+ },
77
+ }
78
+ }