@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/status.ts ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * What an environment actually resolved to, without booting it.
3
+ *
4
+ * Everything here is already knowable — which database, which schema state,
5
+ * what is pending, what is outstanding — but until now only by starting a
6
+ * server and reading its banner, which is no use when the question is *why* it
7
+ * will not start.
8
+ *
9
+ * Nothing in here writes. `bootstrapDatabase` migrates and seeds, so it is
10
+ * deliberately not used: asking a production node how it is must never be the
11
+ * thing that changes it.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { ObjectTypes } from '@crombolo/core'
17
+ import {
18
+ connect,
19
+ cromboloPlan,
20
+ currentSchemaState,
21
+ type Db,
22
+ INITIAL_STATE,
23
+ pendingMigrations,
24
+ readLedger,
25
+ readReport,
26
+ readState,
27
+ TransferRunRepository,
28
+ } from '@crombolo/data'
29
+ import { loadSchemaDirectory } from '@crombolo/schema'
30
+ import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from './bundle-tables.ts'
31
+ import { type CromboloConfig, describeDatabase, VERSION } from './config.ts'
32
+ import { type EmailAvailability, emailAvailability, resolveEmailPort } from './email.ts'
33
+ import { assertViewRuntime } from './view-runtime.ts'
34
+
35
+ export interface SiteStatus {
36
+ site: {
37
+ name: string
38
+ dir: string
39
+ components: string
40
+ schema: string
41
+ /** Where views are snapshotted so an edit needs no restart. */
42
+ componentsCache: string
43
+ }
44
+ database: {
45
+ dialect: string
46
+ description: string
47
+ reachable: boolean
48
+ /** Why it could not be read, when it could not. */
49
+ problem?: string
50
+ installed: boolean
51
+ }
52
+ schema: {
53
+ /** The version in `schema/schema.toml`, and the revision this node deploys at. */
54
+ files?: string
55
+ revision: string
56
+ /** What the database is at, when it is installed. */
57
+ database?: { version: string; revision: string }
58
+ /** The database is ahead of the files: this node reads and refuses writes. */
59
+ compatibilityMode: boolean
60
+ /** The files are ahead: a sync or an upgrade is pending. */
61
+ pending: boolean
62
+ }
63
+ framework: { pending: string[] }
64
+ /**
65
+ * Each wired bundle that owns tables, by id: its chain's state and the steps
66
+ * still to run. Only a node that mounts bundles applies them, so this is what
67
+ * such a node would refuse to boot over in production.
68
+ */
69
+ bundles: Record<string, { name: string; state: string | null; pending: string[] }>
70
+ content: { documents: number; media: number; elements: number; lastImport?: string }
71
+ /**
72
+ * Open findings by kind. `auto` ones are reported, not counted against the
73
+ * site: "this page is new here" is a note, not a thing to fix.
74
+ */
75
+ findings: { blocking: number; person: number; auto: number; resolved: number }
76
+ versions: { crombolo: string; backoffice?: string; bun: string }
77
+ /**
78
+ * Whether e-mail can leave this site, and what is unavailable when it cannot.
79
+ * Not a problem — no e-mail is a valid configuration — so it is reported
80
+ * rather than counted against the site.
81
+ */
82
+ email: EmailAvailability
83
+ /** Anything that would stop this site working, in the order worth fixing. */
84
+ problems: string[]
85
+ }
86
+
87
+ /** `1.1.0` against `1.0.0`: which way round the two versions are. */
88
+ function compareVersions(a: string, b: string): number {
89
+ const parts = (value: string) => value.split('.').map((part) => Number(part) || 0)
90
+ const [left, right] = [parts(a), parts(b)]
91
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
92
+ const difference = (left[i] ?? 0) - (right[i] ?? 0)
93
+ if (difference !== 0) return difference
94
+ }
95
+ return 0
96
+ }
97
+
98
+ async function contentCounts(db: Db): Promise<SiteStatus['content']> {
99
+ const rows = await db.query<{ node_object_type: string; n: number }>(
100
+ `SELECT node_object_type, COUNT(*) AS n FROM node
101
+ WHERE trashed = ? GROUP BY node_object_type`,
102
+ [db.dialect.boolValue(false)],
103
+ )
104
+ const of = (objectType: string) =>
105
+ Number(rows.find((row) => row.node_object_type === objectType)?.n ?? 0)
106
+ return {
107
+ documents: of(ObjectTypes.Document),
108
+ media: of(ObjectTypes.Media),
109
+ elements: of(ObjectTypes.Element),
110
+ }
111
+ }
112
+
113
+ /** The vendored client's version, which is a fact about this deployment too. */
114
+ function backofficeVersion(): string | undefined {
115
+ for (const base of [join(import.meta.dir, '..', '..'), process.cwd()]) {
116
+ const file = join(base, 'backoffice-dist', 'package.json')
117
+ if (!existsSync(file)) continue
118
+ try {
119
+ return (JSON.parse(readFileSync(file, 'utf8')) as { version?: string }).version
120
+ } catch {
121
+ return undefined
122
+ }
123
+ }
124
+ return undefined
125
+ }
126
+
127
+ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
128
+ const loaded = existsSync(config.schemaDir) ? loadSchemaDirectory(config.schemaDir) : undefined
129
+ const status: SiteStatus = {
130
+ site: {
131
+ name: config.siteName,
132
+ dir: config.siteDir,
133
+ components: config.componentsDir,
134
+ schema: config.schemaDir,
135
+ componentsCache: config.componentsCacheDir,
136
+ },
137
+ database: {
138
+ dialect: config.dialect,
139
+ description: describeDatabase(config),
140
+ reachable: false,
141
+ installed: false,
142
+ },
143
+ schema: {
144
+ files: loaded?.set.version,
145
+ revision: config.schemaRevision,
146
+ compatibilityMode: false,
147
+ pending: false,
148
+ },
149
+ framework: { pending: [] },
150
+ bundles: {},
151
+ content: { documents: 0, media: 0, elements: 0 },
152
+ findings: { blocking: 0, person: 0, auto: 0, resolved: 0 },
153
+ versions: { crombolo: VERSION, backoffice: backofficeVersion(), bun: Bun.version },
154
+ email: emailAvailability(resolveEmailPort(config)),
155
+ problems: [],
156
+ }
157
+
158
+ // Opening a SQLite file creates it, which is a change — and the question
159
+ // "what is this environment?" must not be the thing that makes one.
160
+ // Checked here as well as at boot, so a deploy learns before it serves: a
161
+ // views cache the JSX runtime does not resolve from fails every render.
162
+ try {
163
+ assertViewRuntime(config.componentsCacheDir)
164
+ } catch (error) {
165
+ status.problems.push((error as Error).message.split('\n')[0] as string)
166
+ }
167
+
168
+ if (
169
+ config.dialect === 'sqlite' &&
170
+ config.sqliteFile !== ':memory:' &&
171
+ !existsSync(config.sqliteFile)
172
+ ) {
173
+ status.database.reachable = true
174
+ status.problems.push('there is no database yet; `crombolo start` will create one')
175
+ return status
176
+ }
177
+
178
+ let db: Db | undefined
179
+ try {
180
+ db = await connect({ file: config.sqliteFile })
181
+ status.database.reachable = true
182
+ try {
183
+ status.database.installed = (await readState(db)) !== INITIAL_STATE
184
+ } catch {
185
+ // Reachable, with no tables in it at all: not installed, rather than
186
+ // unreachable. A half-finished install looks like this.
187
+ status.database.installed = false
188
+ }
189
+
190
+ status.framework.pending = (await pendingMigrations(db, cromboloPlan)).map((m) => m.name)
191
+ if (!status.database.installed) {
192
+ status.problems.push('the database is not installed yet; `crombolo start` will install it')
193
+ return status
194
+ }
195
+ if (status.framework.pending.length > 0 && !config.development)
196
+ status.problems.push(
197
+ `${status.framework.pending.length} framework migration(s) pending: run \`crombolo upgrade\``,
198
+ )
199
+
200
+ try {
201
+ const chains = bundleChainsFor(config)
202
+ for (const chain of chains)
203
+ status.bundles[chain.id] = { name: chain.name, state: null, pending: [] }
204
+ for (const bundle of await pendingBundleSteps(db, chains)) {
205
+ status.bundles[bundle.id] = {
206
+ name: bundle.name,
207
+ state: bundle.state,
208
+ pending: bundle.pending.map((step) => step.name),
209
+ }
210
+ if (bundle.pending.length > 0 && !config.development)
211
+ status.problems.push(
212
+ `the bundle "${bundle.id}" has ${bundle.pending.length} step(s) pending: run \`crombolo upgrade\``,
213
+ )
214
+ }
215
+ } catch (error) {
216
+ if (error instanceof BundleConfigurationError)
217
+ for (const problem of error.problems) status.problems.push(problem)
218
+ else status.problems.push((error as Error).message)
219
+ }
220
+
221
+ const state = await currentSchemaState(db)
222
+ status.schema.database = { version: state.version, revision: state.revision }
223
+ if (loaded) {
224
+ const difference = compareVersions(loaded.set.version, state.version)
225
+ status.schema.compatibilityMode = difference < 0
226
+ status.schema.pending =
227
+ difference > 0 || (difference === 0 && config.schemaRevision > state.revision)
228
+ if (status.schema.compatibilityMode)
229
+ status.problems.push(
230
+ `the database is at ${state.version} and these files are ${loaded.set.version}: reads only, writes refused with 409`,
231
+ )
232
+ if (status.schema.pending)
233
+ status.problems.push(
234
+ 'schema/ is ahead of the database: run `crombolo upgrade check` to see what it would do',
235
+ )
236
+ for (const problem of loaded.problems)
237
+ status.problems.push(`${problem.file}: ${problem.message}`)
238
+ }
239
+
240
+ status.content = await contentCounts(db)
241
+ const [run] = await new TransferRunRepository(db).recent(1)
242
+ if (run) status.content.lastImport = run.startedAt.toISOString()
243
+
244
+ const open = await readReport(db)
245
+ const all = await readReport(db, { includeResolved: true })
246
+ const of = (kind: string) => open.filter((finding) => finding.kind === kind).length
247
+ status.findings = {
248
+ blocking: of('blocking'),
249
+ person: of('person'),
250
+ auto: of('auto'),
251
+ resolved: all.length - open.length,
252
+ }
253
+ const wanting = status.findings.blocking + status.findings.person
254
+ if (wanting > 0)
255
+ status.problems.push(
256
+ `${wanting} finding(s) need somebody: \`crombolo upgrade check\` or the Changes dashboard`,
257
+ )
258
+ // Read so that a database whose ledger cannot be read is noticed here
259
+ // rather than at the next upgrade.
260
+ await readLedger(db)
261
+ } catch (error) {
262
+ status.database.problem = (error as Error).message
263
+ status.problems.push(
264
+ status.database.reachable
265
+ ? `the database could not be read: ${(error as Error).message}`
266
+ : `the database cannot be reached: ${(error as Error).message}`,
267
+ )
268
+ } finally {
269
+ // One exit, so a connection is never left open: on Postgres a leaked
270
+ // session blocks the next `DROP SCHEMA`, and the symptom is a test run
271
+ // that slows to a crawl rather than anything that looks like this.
272
+ await db?.close()
273
+ }
274
+ return status
275
+ }
package/src/tar.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A minimal npm package tarball writer: a gzipped ustar archive with every entry
3
+ * under `package/`, which is the layout `npm pack` produces and `bun add
4
+ * ./file.tgz` and `bun publish` read (`docs/bundles.md`).
5
+ *
6
+ * No dependency: the format is 512-byte headers and padded file bodies, and
7
+ * `Bun.gzipSync` does the compression. Timestamps are fixed, as npm fixes them,
8
+ * so the same selection packs to the same bytes.
9
+ */
10
+
11
+ export interface TarEntry {
12
+ /** The path inside the package, `/`-separated and never absolute. */
13
+ path: string
14
+ bytes: Uint8Array
15
+ }
16
+
17
+ const BLOCK = 512
18
+
19
+ /** 1985-10-26T08:15:00Z, the mtime npm writes into every packed file. */
20
+ const NPM_MTIME = 499162500
21
+
22
+ const encoder = new TextEncoder()
23
+
24
+ /** An octal field, NUL-terminated and zero-padded to its width. */
25
+ function octal(value: number, width: number): Uint8Array {
26
+ return encoder.encode(`${value.toString(8).padStart(width - 1, '0')}\0`)
27
+ }
28
+
29
+ /**
30
+ * Splits a path across ustar's `prefix` (155 bytes) and `name` (100 bytes) at a
31
+ * slash, which is how a path longer than 100 bytes is written without an
32
+ * extension header.
33
+ */
34
+ function splitPath(path: string): { name: string; prefix: string } {
35
+ if (encoder.encode(path).length <= 100) return { name: path, prefix: '' }
36
+ for (let i = path.lastIndexOf('/'); i > 0; i = path.lastIndexOf('/', i - 1)) {
37
+ const prefix = path.slice(0, i)
38
+ const name = path.slice(i + 1)
39
+ if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100)
40
+ return { name, prefix }
41
+ }
42
+ throw new Error(`${path} is too long to pack`)
43
+ }
44
+
45
+ function header(path: string, size: number): Uint8Array {
46
+ const block = new Uint8Array(BLOCK)
47
+ const put = (bytes: Uint8Array, offset: number) => block.set(bytes, offset)
48
+ const { name, prefix } = splitPath(path)
49
+ put(encoder.encode(name), 0)
50
+ put(octal(0o644, 8), 100)
51
+ put(octal(0, 8), 108)
52
+ put(octal(0, 8), 116)
53
+ put(octal(size, 12), 124)
54
+ put(octal(NPM_MTIME, 12), 136)
55
+ // The checksum is computed with its own field read as spaces.
56
+ put(encoder.encode(' '), 148)
57
+ put(encoder.encode('0'), 156)
58
+ put(encoder.encode('ustar\0'), 257)
59
+ put(encoder.encode('00'), 263)
60
+ put(encoder.encode(prefix), 345)
61
+ const sum = block.reduce((total, byte) => total + byte, 0)
62
+ put(encoder.encode(`${sum.toString(8).padStart(6, '0')}\0 `), 148)
63
+ return block
64
+ }
65
+
66
+ /** The entries as an npm package tarball, each under `package/`. */
67
+ export function writeNpmTarball(entries: readonly TarEntry[]): Uint8Array<ArrayBuffer> {
68
+ const parts: Uint8Array[] = []
69
+ for (const entry of entries) {
70
+ if (entry.path.startsWith('/') || entry.path.split('/').includes('..'))
71
+ throw new Error(`${entry.path} is not a path inside the package`)
72
+ parts.push(header(`package/${entry.path}`, entry.bytes.length), entry.bytes)
73
+ const pad = (BLOCK - (entry.bytes.length % BLOCK)) % BLOCK
74
+ if (pad > 0) parts.push(new Uint8Array(pad))
75
+ }
76
+ // Two empty blocks end the archive.
77
+ parts.push(new Uint8Array(BLOCK * 2))
78
+ const tar = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
79
+ let offset = 0
80
+ for (const part of parts) {
81
+ tar.set(part, offset)
82
+ offset += part.length
83
+ }
84
+ return Bun.gzipSync(tar) as Uint8Array<ArrayBuffer>
85
+ }
package/src/udt.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Umbraco's dictionary export format (`.udt`): nested `<DictionaryItem Key Name>`
3
+ * elements, each with a `<Value LanguageCultureAlias>` per translation in CDATA.
4
+ * The reader understands only the XML this format uses: elements, attributes,
5
+ * text, CDATA, comments and the prolog.
6
+ */
7
+
8
+ export interface UdtItem {
9
+ key: string
10
+ name: string
11
+ translations: Array<{ isoCode: string; translation: string }>
12
+ children: UdtItem[]
13
+ }
14
+
15
+ export interface XmlElement {
16
+ name: string
17
+ attributes: Record<string, string>
18
+ children: XmlElement[]
19
+ text: string
20
+ }
21
+
22
+ /** XML text content: the three characters that cannot appear raw. */
23
+ export const escapeText = (value: string) =>
24
+ value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
25
+
26
+ const escapeAttribute = (value: string) =>
27
+ value
28
+ .replaceAll('&', '&amp;')
29
+ .replaceAll('<', '&lt;')
30
+ .replaceAll('>', '&gt;')
31
+ .replaceAll('"', '&quot;')
32
+
33
+ const cdata = (value: string) => `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`
34
+
35
+ function writeItem(item: UdtItem, indent: string): string {
36
+ const inner = [
37
+ ...item.translations.map(
38
+ (t) =>
39
+ `${indent} <Value LanguageCultureAlias="${escapeAttribute(t.isoCode)}">${cdata(t.translation)}</Value>`,
40
+ ),
41
+ ...item.children.map((child) => writeItem(child, `${indent} `)),
42
+ ]
43
+ const open = `${indent}<DictionaryItem Key="${escapeAttribute(item.key)}" Name="${escapeAttribute(item.name)}"`
44
+ return inner.length === 0
45
+ ? `${open} />`
46
+ : `${open}>\n${inner.join('\n')}\n${indent}</DictionaryItem>`
47
+ }
48
+
49
+ /** One item, and its descendants when they are included, as a `.udt` document. */
50
+ export function writeUdt(item: UdtItem): string {
51
+ return `<?xml version="1.0" encoding="utf-8"?>\n${writeItem(item, '')}`
52
+ }
53
+
54
+ /**
55
+ * Several items as one `.udt` document, under the `<DictionaryItems>` wrapper
56
+ * `readUdt` already accepts — which is what a whole-dictionary export needs,
57
+ * since the tree has more than one root.
58
+ */
59
+ export function writeUdtAll(items: readonly UdtItem[]): string {
60
+ if (items.length === 1) return writeUdt(items[0] as UdtItem)
61
+ const inner = items.map((item) => writeItem(item, ' ')).join('\n')
62
+ return `<?xml version="1.0" encoding="utf-8"?>\n<DictionaryItems>\n${inner}\n</DictionaryItems>`
63
+ }
64
+
65
+ const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
66
+
67
+ const decode = (value: string) =>
68
+ value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, entity: string) => {
69
+ if (entity.startsWith('#x') || entity.startsWith('#X'))
70
+ return String.fromCodePoint(Number.parseInt(entity.slice(2), 16))
71
+ if (entity.startsWith('#')) return String.fromCodePoint(Number.parseInt(entity.slice(1), 10))
72
+ return ENTITIES[entity.toLowerCase()] ?? whole
73
+ })
74
+
75
+ /** Parses a document into its root element; undefined when it is not well formed. */
76
+ export function parseXml(source: string): XmlElement | undefined {
77
+ const root: XmlElement = { name: '#document', attributes: {}, children: [], text: '' }
78
+ const stack: XmlElement[] = [root]
79
+ let i = 0
80
+ const top = () => stack[stack.length - 1] as XmlElement
81
+ while (i < source.length) {
82
+ const lt = source.indexOf('<', i)
83
+ if (lt === -1) {
84
+ top().text += decode(source.slice(i))
85
+ break
86
+ }
87
+ top().text += decode(source.slice(i, lt))
88
+ if (source.startsWith('<![CDATA[', lt)) {
89
+ const end = source.indexOf(']]>', lt)
90
+ if (end === -1) return undefined
91
+ top().text += source.slice(lt + 9, end)
92
+ i = end + 3
93
+ } else if (source.startsWith('<!--', lt)) {
94
+ const end = source.indexOf('-->', lt)
95
+ if (end === -1) return undefined
96
+ i = end + 3
97
+ } else if (source.startsWith('<?', lt) || source.startsWith('<!', lt)) {
98
+ const end = source.indexOf('>', lt)
99
+ if (end === -1) return undefined
100
+ i = end + 1
101
+ } else if (source.startsWith('</', lt)) {
102
+ const end = source.indexOf('>', lt)
103
+ if (end === -1) return undefined
104
+ const name = source.slice(lt + 2, end).trim()
105
+ const closing = stack.pop()
106
+ if (!closing || closing.name !== name) return undefined
107
+ i = end + 1
108
+ } else {
109
+ const tag = /^<([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/.exec(
110
+ source.slice(lt),
111
+ )
112
+ if (!tag) return undefined
113
+ const element: XmlElement = {
114
+ name: tag[1] as string,
115
+ attributes: {},
116
+ children: [],
117
+ text: '',
118
+ }
119
+ for (const a of (tag[2] ?? '').matchAll(/([\w.:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
120
+ element.attributes[a[1] as string] = decode(a[2] ?? a[3] ?? '')
121
+ top().children.push(element)
122
+ if (tag[3] !== '/') stack.push(element)
123
+ i = lt + tag[0].length
124
+ }
125
+ }
126
+ if (stack.length !== 1) return undefined
127
+ return root.children[0]
128
+ }
129
+
130
+ function readItem(element: XmlElement): UdtItem | undefined {
131
+ const key = element.attributes.Key
132
+ const name = element.attributes.Name
133
+ if (!key || !name) return undefined
134
+ const children: UdtItem[] = []
135
+ for (const child of element.children.filter((c) => c.name === 'DictionaryItem')) {
136
+ const item = readItem(child)
137
+ if (!item) return undefined
138
+ children.push(item)
139
+ }
140
+ return {
141
+ key,
142
+ name,
143
+ translations: element.children
144
+ .filter((c) => c.name === 'Value' && c.attributes.LanguageCultureAlias)
145
+ .map((c) => ({ isoCode: c.attributes.LanguageCultureAlias as string, translation: c.text })),
146
+ children,
147
+ }
148
+ }
149
+
150
+ /** The items a `.udt` holds: one root item, or several under `<DictionaryItems>`. */
151
+ export function readUdt(source: string): UdtItem[] | undefined {
152
+ const root = parseXml(source)
153
+ if (!root) return undefined
154
+ const elements =
155
+ root.name === 'DictionaryItems'
156
+ ? root.children.filter((c) => c.name === 'DictionaryItem')
157
+ : root.name === 'DictionaryItem'
158
+ ? [root]
159
+ : []
160
+ if (elements.length === 0) return undefined
161
+ const items: UdtItem[] = []
162
+ for (const element of elements) {
163
+ const item = readItem(element)
164
+ if (!item) return undefined
165
+ items.push(item)
166
+ }
167
+ return items
168
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * JSON Schema for what a property or a whole content type holds.
3
+ *
4
+ * Umbraco derives these from the .NET type a property editor stores, so the
5
+ * names it reports are .NET's. The shape is what a caller needs to validate or
6
+ * generate a value, which for us follows the data type's storage column.
7
+ */
8
+ import type { ValueStorageTypeName } from '@crombolo/core'
9
+
10
+ /** The .NET name Umbraco reports for each storage type. */
11
+ export const VALUE_TYPE_NAMES: Record<ValueStorageTypeName, string> = {
12
+ Ntext: 'System.String',
13
+ Nvarchar: 'System.String',
14
+ Integer: 'System.Int32',
15
+ Decimal: 'System.Decimal',
16
+ Date: 'System.DateTime',
17
+ }
18
+
19
+ export function schemaForStorage(storage: string): Record<string, unknown> {
20
+ switch (storage) {
21
+ case 'Integer':
22
+ return { type: 'integer' }
23
+ case 'Decimal':
24
+ return { type: 'number' }
25
+ case 'Date':
26
+ return { type: 'string', format: 'date-time' }
27
+ default:
28
+ return { type: 'string' }
29
+ }
30
+ }
31
+
32
+ export const valueTypeName = (storage: string): string =>
33
+ VALUE_TYPE_NAMES[storage as ValueStorageTypeName] ?? 'System.String'
34
+
35
+ export interface SchemaProperty {
36
+ alias: string
37
+ name: string
38
+ description: string | null
39
+ mandatory: boolean
40
+ storage: string
41
+ }
42
+
43
+ /** A content type as a JSON Schema object: one property per alias. */
44
+ export function contentTypeJsonSchema(
45
+ type: { alias: string; name: string; description: string | null },
46
+ properties: readonly SchemaProperty[],
47
+ ): Record<string, unknown> {
48
+ const shape: Record<string, unknown> = {}
49
+ for (const property of properties) {
50
+ const schema = schemaForStorage(property.storage) as Record<string, unknown>
51
+ shape[property.alias] = property.description
52
+ ? { ...schema, title: property.name, description: property.description }
53
+ : { ...schema, title: property.name }
54
+ }
55
+ const required = properties.filter((p) => p.mandatory).map((p) => p.alias)
56
+ return {
57
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
58
+ title: type.name,
59
+ description: type.description ?? undefined,
60
+ type: 'object',
61
+ properties: shape,
62
+ ...(required.length > 0 ? { required } : {}),
63
+ }
64
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Proving a directory can import a view, before one has to.
3
+ *
4
+ * JSX compiles to an import of `@crombolo/crombolo/jsx-runtime`, which resolves by walking
5
+ * up from the file that needs it. So a views directory — or a snapshot of one —
6
+ * outside the site's own tree cannot load at all:
7
+ *
8
+ * Cannot find package '@crombolo/crombolo' imported from /mnt/views/homePage.tsx
9
+ *
10
+ * Checked at boot because the alternative is every render failing with a message
11
+ * that names the runtime rather than the configuration that moved it.
12
+ */
13
+ import { mkdirSync } from 'node:fs'
14
+
15
+ export const JSX_RUNTIME = '@crombolo/crombolo/jsx-runtime'
16
+
17
+ export class ViewRuntimeUnreachableError extends Error {
18
+ constructor(
19
+ readonly dir: string,
20
+ readonly reason: string,
21
+ ) {
22
+ super(
23
+ `Views cannot be imported from ${dir}: '${JSX_RUNTIME}' does not resolve from there (${reason}).\n` +
24
+ ' JSX compiles to an import of it, resolved by walking up from the file, so the directory\n' +
25
+ ' has to sit inside a tree whose node_modules carries @crombolo/crombolo. Move it under the site, or\n' +
26
+ ' set CROMBOLO_COMPONENTS_CACHE_DIR to somewhere that is.',
27
+ )
28
+ }
29
+ }
30
+
31
+ /** Throws unless a `.tsx` placed in `dir` could resolve the JSX runtime. */
32
+ export function assertViewRuntime(dir: string): void {
33
+ mkdirSync(dir, { recursive: true })
34
+ try {
35
+ Bun.resolveSync(JSX_RUNTIME, dir)
36
+ } catch (error) {
37
+ throw new ViewRuntimeUnreachableError(
38
+ dir,
39
+ error instanceof Error ? error.message : String(error),
40
+ )
41
+ }
42
+ }