@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- 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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
25
|
+
|
|
26
|
+
const escapeAttribute = (value: string) =>
|
|
27
|
+
value
|
|
28
|
+
.replaceAll('&', '&')
|
|
29
|
+
.replaceAll('<', '<')
|
|
30
|
+
.replaceAll('>', '>')
|
|
31
|
+
.replaceAll('"', '"')
|
|
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
|
+
}
|