@crombolo/cli 0.13.2 → 0.14.4
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/bin/crombolo.ts +689 -23
- package/package.json +7 -7
- package/src/operations/commands.ts +108 -4
- package/src/remote/client.ts +288 -0
- package/src/remote/remotes-file.ts +80 -0
- package/src/remote/transfer.ts +337 -0
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/content/bundle.json +11 -4
- package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json +42 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +7 -0
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- package/templates/demo/harbourstone/files/components/pages/searchPage.tsx +76 -0
- package/templates/demo/harbourstone/files/components/shared/site-header.tsx +13 -1
- package/templates/demo/harbourstone/files/css/site.css +113 -0
- package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +2 -0
- package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +2 -0
- package/templates/demo/harbourstone/files/schema/content-types/expression.toml +8 -0
- package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +5 -1
- package/templates/demo/harbourstone/files/schema/content-types/search-page.toml +33 -0
- package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +2 -0
- package/templates/starter/bundle/content/bundle.json +12 -5
- package/templates/starter/bundle/content/nodes/c720ef2a-b5c8-48ec-a504-065e8188d4aa.json +28 -0
- package/templates/starter/bundle/package.json +1 -1
- package/templates/starter/files/components/pages/searchPage.tsx +57 -0
- package/templates/starter/files/components/shared/site-header.tsx +11 -2
- package/templates/starter/files/css/site.css +66 -0
- package/templates/starter/files/schema/content-types/content-page.toml +4 -1
- package/templates/starter/files/schema/content-types/search-page.toml +21 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crombolo pull` and `crombolo push` as functions (story 108): they take a
|
|
3
|
+
* signed-in client and options, and return what happened; the binary prints.
|
|
4
|
+
*
|
|
5
|
+
* A pull is a job on the remote ending in a sealed file, opened here with a
|
|
6
|
+
* passphrase made for that one pull and kept in memory. A push exports this
|
|
7
|
+
* site sealed the same way, checks with the remote what it would replace, and
|
|
8
|
+
* uploads it as a job — which the remote may hold for approval.
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes } from 'node:crypto'
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import {
|
|
15
|
+
bootstrapDatabase,
|
|
16
|
+
type CromboloConfig,
|
|
17
|
+
exportSite,
|
|
18
|
+
extractTarball,
|
|
19
|
+
type MediaStore,
|
|
20
|
+
mediaStoreFor,
|
|
21
|
+
} from '@crombolo/server'
|
|
22
|
+
import { type BundleBlob, loadBundle } from '@crombolo/transfer'
|
|
23
|
+
import { type Job, problemOf, type RemoteClient, RemoteError, sha256File } from './client.ts'
|
|
24
|
+
|
|
25
|
+
export type MediaMode = 'included' | 'omitted' | 'referenced'
|
|
26
|
+
|
|
27
|
+
/** A passphrase for one transfer: never shown, never stored. */
|
|
28
|
+
export const transferPassphrase = () => randomBytes(24).toString('base64url')
|
|
29
|
+
|
|
30
|
+
/** Refused before anything was sent: the CLI's own guard, exit code 2. */
|
|
31
|
+
export class RefusedBeforeSending extends Error {}
|
|
32
|
+
|
|
33
|
+
export interface PullResult {
|
|
34
|
+
job: Job
|
|
35
|
+
file: string
|
|
36
|
+
passphrase: string
|
|
37
|
+
size: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Asks the remote for its site and downloads the file to `out`. */
|
|
41
|
+
export async function pullFile(
|
|
42
|
+
client: RemoteClient,
|
|
43
|
+
options: {
|
|
44
|
+
environment: string
|
|
45
|
+
out: string
|
|
46
|
+
full?: boolean
|
|
47
|
+
media?: MediaMode
|
|
48
|
+
onProgress?(job: Job): void
|
|
49
|
+
timeoutMs?: number
|
|
50
|
+
sleep?: (ms: number) => Promise<void>
|
|
51
|
+
},
|
|
52
|
+
): Promise<PullResult> {
|
|
53
|
+
const environments = await client.environments()
|
|
54
|
+
const env = environments.find((e) => e.id === options.environment)
|
|
55
|
+
if (!env)
|
|
56
|
+
throw new RefusedBeforeSending(
|
|
57
|
+
`${client.discovery.name} has no environment "${options.environment}": it has ${environments.map((e) => e.id).join(', ')}.`,
|
|
58
|
+
)
|
|
59
|
+
if (!env.may.pull) throw new RefusedBeforeSending(`You may not pull from ${env.name}.`)
|
|
60
|
+
if (options.full && !env.may.pullFull)
|
|
61
|
+
throw new RefusedBeforeSending(
|
|
62
|
+
`You may not pull a full copy of ${env.name}; pull without --full for an anonymised one.`,
|
|
63
|
+
)
|
|
64
|
+
if (options.media === 'referenced' && !client.discovery.capabilities.includes('media-fetch'))
|
|
65
|
+
throw new RefusedBeforeSending(
|
|
66
|
+
`${client.discovery.name} does not serve media by reference; pull without --media-info-only.`,
|
|
67
|
+
)
|
|
68
|
+
const passphrase = transferPassphrase()
|
|
69
|
+
const started = await client.post<Job>(`environments/${env.id}/pulls`, {
|
|
70
|
+
passphrase,
|
|
71
|
+
full: options.full === true,
|
|
72
|
+
media: options.media ?? 'included',
|
|
73
|
+
})
|
|
74
|
+
const job = await client.settle(started.id, {
|
|
75
|
+
onProgress: options.onProgress,
|
|
76
|
+
timeoutMs: options.timeoutMs,
|
|
77
|
+
sleep: options.sleep,
|
|
78
|
+
})
|
|
79
|
+
if (job.state !== 'done') throw new RemoteError(job.problem ?? jobProblem(job))
|
|
80
|
+
const { size } = await client.download(job, options.out)
|
|
81
|
+
return { job, file: options.out, passphrase, size }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const jobProblem = (job: Job) => ({
|
|
85
|
+
status: 0,
|
|
86
|
+
code: job.state,
|
|
87
|
+
title: `The ${job.kind} ended ${job.state}`,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** What the local site has already fetched of a remote's media, by key: the remote's etag. */
|
|
91
|
+
const FETCHED_FILE = join('.crombolo', 'remote-media-fetched.json')
|
|
92
|
+
|
|
93
|
+
function readFetched(siteDir: string): Record<string, string> {
|
|
94
|
+
const file = join(siteDir, FETCHED_FILE)
|
|
95
|
+
if (!existsSync(file)) return {}
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(readFileSync(file, 'utf8')) as Record<string, string>
|
|
98
|
+
} catch {
|
|
99
|
+
return {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MediaFetchResult {
|
|
104
|
+
fetched: number
|
|
105
|
+
skipped: number
|
|
106
|
+
bytes: number
|
|
107
|
+
failed: string[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fetches the media files a file names by reference into the local store, a
|
|
112
|
+
* batch of links at a time and a few files at once. A file already fetched at
|
|
113
|
+
* the same etag is skipped, so a second pull downloads only what changed.
|
|
114
|
+
*/
|
|
115
|
+
export async function fetchReferencedMedia(
|
|
116
|
+
client: RemoteClient,
|
|
117
|
+
options: {
|
|
118
|
+
environment: string
|
|
119
|
+
blobs: readonly Pick<BundleBlob, 'key' | 'etag'>[]
|
|
120
|
+
store: MediaStore
|
|
121
|
+
siteDir: string
|
|
122
|
+
onProgress?(done: number, total: number): void
|
|
123
|
+
http?: typeof fetch
|
|
124
|
+
},
|
|
125
|
+
): Promise<MediaFetchResult> {
|
|
126
|
+
const fetched = readFetched(options.siteDir)
|
|
127
|
+
const result: MediaFetchResult = { fetched: 0, skipped: 0, bytes: 0, failed: [] }
|
|
128
|
+
const wanted: string[] = []
|
|
129
|
+
for (const blob of options.blobs) {
|
|
130
|
+
const have = await options.store.get(blob.key)
|
|
131
|
+
if (have && blob.etag && fetched[blob.key] === blob.etag) result.skipped++
|
|
132
|
+
else wanted.push(blob.key)
|
|
133
|
+
}
|
|
134
|
+
let done = 0
|
|
135
|
+
for (let at = 0; at < wanted.length; at += 200) {
|
|
136
|
+
const batch = wanted.slice(at, at + 200)
|
|
137
|
+
const { links } = await client.post<{
|
|
138
|
+
links: Record<string, { url: string; etag: string; size: number } | null>
|
|
139
|
+
}>(`environments/${options.environment}/media/links`, { keys: batch })
|
|
140
|
+
const queue = [...batch]
|
|
141
|
+
const worker = async () => {
|
|
142
|
+
for (;;) {
|
|
143
|
+
const key = queue.shift()
|
|
144
|
+
if (!key) return
|
|
145
|
+
const link = links[key]
|
|
146
|
+
try {
|
|
147
|
+
if (!link) throw new Error('not on the remote')
|
|
148
|
+
// A link through the API needs the sign-in; a presigned one does not.
|
|
149
|
+
const response = link.url.startsWith(client.discovery.api)
|
|
150
|
+
? await client.call(link.url)
|
|
151
|
+
: await (options.http ?? fetch)(link.url)
|
|
152
|
+
if (!response.ok) throw await problemOf(response)
|
|
153
|
+
const body = await response.blob()
|
|
154
|
+
await options.store.put(key, body)
|
|
155
|
+
fetched[key] = link.etag
|
|
156
|
+
result.fetched++
|
|
157
|
+
result.bytes += body.size
|
|
158
|
+
} catch {
|
|
159
|
+
result.failed.push(key)
|
|
160
|
+
}
|
|
161
|
+
options.onProgress?.(++done, wanted.length)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await Promise.all(Array.from({ length: 6 }, worker))
|
|
165
|
+
}
|
|
166
|
+
mkdirSync(join(options.siteDir, '.crombolo'), { recursive: true })
|
|
167
|
+
writeFileSync(join(options.siteDir, FETCHED_FILE), `${JSON.stringify(fetched, null, 2)}\n`)
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The blobs a file names by reference, read from its manifest. */
|
|
172
|
+
export async function referencedBlobs(file: string): Promise<BundleBlob[]> {
|
|
173
|
+
const dir = mkdtempSync(join(tmpdir(), 'crombolo-pulled-'))
|
|
174
|
+
try {
|
|
175
|
+
await extractTarball(file, dir)
|
|
176
|
+
const manifest = loadBundle(join(dir, 'content')).set?.manifest
|
|
177
|
+
return manifest?.media === 'referenced' ? manifest.blobs : []
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(dir, { recursive: true, force: true })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface PushCheck {
|
|
184
|
+
environment: string
|
|
185
|
+
recycles: Record<string, number>
|
|
186
|
+
removesFiles: string[]
|
|
187
|
+
keepsPeople: boolean
|
|
188
|
+
/** Whether the remote answered it, or it is only what this side is sending. */
|
|
189
|
+
fromRemote: boolean
|
|
190
|
+
sending: { counts: Record<string, number>; files: number; media: number }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface PushResult {
|
|
194
|
+
check: PushCheck
|
|
195
|
+
job: Job
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Exports this site and sends it. `confirm` is asked with the check for a
|
|
200
|
+
* protected environment unless `autoApprove`; `onApproval` hears the job when
|
|
201
|
+
* the remote holds it for approval. The flags are this side's guard against a
|
|
202
|
+
* slip, never authority: the remote decides.
|
|
203
|
+
*/
|
|
204
|
+
export async function pushSite(
|
|
205
|
+
config: CromboloConfig,
|
|
206
|
+
client: RemoteClient,
|
|
207
|
+
options: {
|
|
208
|
+
environment: string
|
|
209
|
+
force?: boolean
|
|
210
|
+
autoApprove?: boolean
|
|
211
|
+
mediaInfoOnly?: boolean
|
|
212
|
+
confirm?(check: PushCheck): Promise<boolean>
|
|
213
|
+
onCheck?(check: PushCheck): void
|
|
214
|
+
onProgress?(job: Job): void
|
|
215
|
+
onApproval?(job: Job): void | Promise<void>
|
|
216
|
+
onMedia?(done: number, total: number): void
|
|
217
|
+
timeoutMs?: number
|
|
218
|
+
sleep?: (ms: number) => Promise<void>
|
|
219
|
+
},
|
|
220
|
+
): Promise<PushResult> {
|
|
221
|
+
const environments = await client.environments()
|
|
222
|
+
const env = environments.find((e) => e.id === options.environment)
|
|
223
|
+
if (!env)
|
|
224
|
+
throw new RefusedBeforeSending(
|
|
225
|
+
`${client.discovery.name} has no environment "${options.environment}".`,
|
|
226
|
+
)
|
|
227
|
+
if (!env.may.push) throw new RefusedBeforeSending(`You may not push to ${env.name}.`)
|
|
228
|
+
if (env.protected && !options.force)
|
|
229
|
+
throw new RefusedBeforeSending(
|
|
230
|
+
`${env.name} is protected: pushing replaces what people rely on. Pass --force to go on.`,
|
|
231
|
+
)
|
|
232
|
+
if (options.mediaInfoOnly && !client.discovery.capabilities.includes('media-fetch'))
|
|
233
|
+
throw new RefusedBeforeSending(
|
|
234
|
+
`${client.discovery.name} does not take media by reference; push without --media-info-only.`,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
const work = mkdtempSync(join(tmpdir(), 'crombolo-push-'))
|
|
238
|
+
try {
|
|
239
|
+
const out = join(work, 'site.tgz')
|
|
240
|
+
const passphrase = transferPassphrase()
|
|
241
|
+
const { db } = await bootstrapDatabase(config)
|
|
242
|
+
try {
|
|
243
|
+
await exportSite(db, config, {
|
|
244
|
+
out,
|
|
245
|
+
sealContent: true,
|
|
246
|
+
passphrase,
|
|
247
|
+
media: options.mediaInfoOnly ? 'referenced' : true,
|
|
248
|
+
})
|
|
249
|
+
} finally {
|
|
250
|
+
await db.close()
|
|
251
|
+
}
|
|
252
|
+
const unpacked = join(work, 'unpacked')
|
|
253
|
+
await extractTarball(out, unpacked)
|
|
254
|
+
const loaded = loadBundle(join(unpacked, 'content'))
|
|
255
|
+
const manifest = loaded.set?.manifest
|
|
256
|
+
if (!manifest) throw new Error('The export could not be read back.')
|
|
257
|
+
const sending = {
|
|
258
|
+
counts: { ...manifest.counts } as Record<string, number>,
|
|
259
|
+
files: loaded.files.size,
|
|
260
|
+
media: manifest.blobs.length,
|
|
261
|
+
}
|
|
262
|
+
const check: PushCheck = client.discovery.capabilities.includes('push-check')
|
|
263
|
+
? {
|
|
264
|
+
...(await client.post<Omit<PushCheck, 'fromRemote' | 'sending'>>(
|
|
265
|
+
`environments/${env.id}/pushes/check`,
|
|
266
|
+
{
|
|
267
|
+
nodes: (loaded.set?.nodes ?? []).map((node) => node.key),
|
|
268
|
+
files: [...loaded.files.keys()],
|
|
269
|
+
},
|
|
270
|
+
)),
|
|
271
|
+
fromRemote: true,
|
|
272
|
+
sending,
|
|
273
|
+
}
|
|
274
|
+
: {
|
|
275
|
+
environment: env.id,
|
|
276
|
+
recycles: {},
|
|
277
|
+
removesFiles: [],
|
|
278
|
+
keepsPeople: true,
|
|
279
|
+
fromRemote: false,
|
|
280
|
+
sending,
|
|
281
|
+
}
|
|
282
|
+
options.onCheck?.(check)
|
|
283
|
+
if (env.protected && !options.autoApprove) {
|
|
284
|
+
const yes = (await options.confirm?.(check)) ?? false
|
|
285
|
+
if (!yes) throw new RefusedBeforeSending('Nothing was sent.')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (options.mediaInfoOnly && manifest.blobs.length > 0) {
|
|
289
|
+
const store = await mediaStoreFor(config)
|
|
290
|
+
const { have } = await client.post<{ have: string[] }>(`environments/${env.id}/media/have`, {
|
|
291
|
+
blobs: manifest.blobs.map((blob) => ({ key: blob.key, etag: blob.etag ?? undefined })),
|
|
292
|
+
})
|
|
293
|
+
const present = new Set(have)
|
|
294
|
+
const missing = manifest.blobs.filter((blob) => !present.has(blob.key))
|
|
295
|
+
let done = 0
|
|
296
|
+
for (const blob of missing) {
|
|
297
|
+
const file = await store.get(blob.key)
|
|
298
|
+
if (file) {
|
|
299
|
+
const bytes = await file.bytes()
|
|
300
|
+
const response = await client.api(
|
|
301
|
+
`environments/${env.id}/media/${blob.key.split('/').map(encodeURIComponent).join('/')}`,
|
|
302
|
+
{
|
|
303
|
+
method: 'PUT',
|
|
304
|
+
headers: {
|
|
305
|
+
'crombolo-sha256': new Bun.CryptoHasher('sha256').update(bytes).digest('hex'),
|
|
306
|
+
},
|
|
307
|
+
body: bytes as BodyInit,
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
if (!response.ok) throw await problemOf(response)
|
|
311
|
+
}
|
|
312
|
+
options.onMedia?.(++done, missing.length)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const response = await client.api(`environments/${env.id}/pushes`, {
|
|
317
|
+
method: 'PUT',
|
|
318
|
+
headers: {
|
|
319
|
+
'content-type': 'application/gzip',
|
|
320
|
+
'crombolo-sha256': await sha256File(out),
|
|
321
|
+
'crombolo-passphrase': passphrase,
|
|
322
|
+
},
|
|
323
|
+
body: Bun.file(out),
|
|
324
|
+
})
|
|
325
|
+
if (!response.ok) throw await problemOf(response)
|
|
326
|
+
const started = (await response.json()) as Job
|
|
327
|
+
const job = await client.settle(started.id, {
|
|
328
|
+
onProgress: options.onProgress,
|
|
329
|
+
onApproval: options.onApproval,
|
|
330
|
+
timeoutMs: options.timeoutMs,
|
|
331
|
+
sleep: options.sleep,
|
|
332
|
+
})
|
|
333
|
+
return { check, job }
|
|
334
|
+
} finally {
|
|
335
|
+
rmSync(work, { recursive: true, force: true })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"id": "af030aee-ba70-4bed-8a27-a3806a79100d",
|
|
4
4
|
"createdAt": "2026-10-01T00:00:00.000Z",
|
|
5
5
|
"createdBy": "bun run build:template",
|
|
6
|
-
"integrity": "sha256-
|
|
6
|
+
"integrity": "sha256-d5915f80be85d8eb3aca0488721ea3b7884e6649049cd6261ced0e7f58d44c80",
|
|
7
7
|
"snapshot": "published",
|
|
8
8
|
"provenance": {
|
|
9
9
|
"siteName": "Harbourstone Distillery",
|
|
10
10
|
"schemaVersion": "1.0.0",
|
|
11
11
|
"schemaRevision": "0",
|
|
12
|
-
"schemaHash": "
|
|
12
|
+
"schemaHash": "ac93ee497083881b983ce2f3e305e8d450a31ca1d27203223d8472fd247cae66"
|
|
13
13
|
},
|
|
14
14
|
"selector": {
|
|
15
15
|
"roots": [
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
"a562f0a2-cb6e-4ead-a1ce-4d180632419f",
|
|
48
48
|
"cd681b4b-9c4e-4624-9c13-450aa6a9c135",
|
|
49
49
|
"40107d59-f3e9-4592-aee8-5b03d4586cbd",
|
|
50
|
-
"3d312cc8-2937-40e1-ba14-fd781c9a862d"
|
|
50
|
+
"3d312cc8-2937-40e1-ba14-fd781c9a862d",
|
|
51
|
+
"9468d1b4-f521-48ef-b044-2ab07b461b96"
|
|
51
52
|
],
|
|
52
53
|
"descendants": true,
|
|
53
54
|
"asGiven": [
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
"counts": {
|
|
58
59
|
"media": 7,
|
|
59
60
|
"element": 20,
|
|
60
|
-
"document":
|
|
61
|
+
"document": 9
|
|
61
62
|
},
|
|
62
63
|
"dependencies": {
|
|
63
64
|
"carried": [
|
|
@@ -83,6 +84,7 @@
|
|
|
83
84
|
"6d8d6398-738f-4f38-ad3c-e5d6e9a971fe",
|
|
84
85
|
"7b4713f8-d917-457d-a9d8-7dd01a3617b4",
|
|
85
86
|
"88ec1185-53b5-439a-a2ce-3f56603be4ff",
|
|
87
|
+
"9468d1b4-f521-48ef-b044-2ab07b461b96",
|
|
86
88
|
"97469df2-107c-4010-8c14-4de88f735529",
|
|
87
89
|
"a2eb439f-71d9-4ea6-89d7-c5bc72618281",
|
|
88
90
|
"a562f0a2-cb6e-4ead-a1ce-4d180632419f",
|
|
@@ -124,6 +126,10 @@
|
|
|
124
126
|
"key": "cc07b313-0843-4aa8-bbda-871c8da728c8",
|
|
125
127
|
"alias": "Image"
|
|
126
128
|
},
|
|
129
|
+
{
|
|
130
|
+
"key": "4308082e-5024-47fc-950d-1ee93a3e304f",
|
|
131
|
+
"alias": "searchPage"
|
|
132
|
+
},
|
|
127
133
|
{
|
|
128
134
|
"key": "55138391-faf2-4795-9ed6-43d581d350a4",
|
|
129
135
|
"alias": "tastingNote"
|
|
@@ -138,6 +144,7 @@
|
|
|
138
144
|
"pages/contentPage",
|
|
139
145
|
"pages/expression",
|
|
140
146
|
"pages/homePage",
|
|
147
|
+
"pages/searchPage",
|
|
141
148
|
"pages/whiskyList"
|
|
142
149
|
],
|
|
143
150
|
"languages": []
|
package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json
CHANGED
|
@@ -67,6 +67,13 @@
|
|
|
67
67
|
"editor": "Umbraco.TextBox",
|
|
68
68
|
"value": "Cask strength"
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"property": "productType",
|
|
72
|
+
"culture": null,
|
|
73
|
+
"segment": null,
|
|
74
|
+
"editor": "Umbraco.TextBox",
|
|
75
|
+
"value": "Whisky"
|
|
76
|
+
},
|
|
70
77
|
{
|
|
71
78
|
"property": "summary",
|
|
72
79
|
"culture": null,
|
package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "9468d1b4-f521-48ef-b044-2ab07b461b96",
|
|
3
|
+
"kind": "document",
|
|
4
|
+
"contentType": {
|
|
5
|
+
"key": "4308082e-5024-47fc-950d-1ee93a3e304f",
|
|
6
|
+
"alias": "searchPage"
|
|
7
|
+
},
|
|
8
|
+
"parent": "d60ef12b-0f0a-4db5-9763-21fa52b8d4da",
|
|
9
|
+
"sortOrder": 3,
|
|
10
|
+
"template": "pages/searchPage",
|
|
11
|
+
"variants": [
|
|
12
|
+
{
|
|
13
|
+
"culture": null,
|
|
14
|
+
"segment": null,
|
|
15
|
+
"name": "Search",
|
|
16
|
+
"published": true
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"values": [
|
|
20
|
+
{
|
|
21
|
+
"property": "eyebrow",
|
|
22
|
+
"culture": null,
|
|
23
|
+
"segment": null,
|
|
24
|
+
"editor": "Umbraco.TextBox",
|
|
25
|
+
"value": "Search"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"property": "standfirst",
|
|
29
|
+
"culture": null,
|
|
30
|
+
"segment": null,
|
|
31
|
+
"editor": "Umbraco.TextArea",
|
|
32
|
+
"value": "Search the range, the tasting notes and the story of the distillery."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"property": "title",
|
|
36
|
+
"culture": null,
|
|
37
|
+
"segment": null,
|
|
38
|
+
"editor": "Umbraco.TextBox",
|
|
39
|
+
"value": "Find it in the cove."
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json
CHANGED
|
@@ -67,6 +67,13 @@
|
|
|
67
67
|
"editor": "Umbraco.TextBox",
|
|
68
68
|
"value": "Lightly peated"
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"property": "productType",
|
|
72
|
+
"culture": null,
|
|
73
|
+
"segment": null,
|
|
74
|
+
"editor": "Umbraco.TextBox",
|
|
75
|
+
"value": "Whisky"
|
|
76
|
+
},
|
|
70
77
|
{
|
|
71
78
|
"property": "summary",
|
|
72
79
|
"culture": null,
|
package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json
CHANGED
|
@@ -67,6 +67,13 @@
|
|
|
67
67
|
"editor": "Umbraco.TextBox",
|
|
68
68
|
"value": "Sherry matured"
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"property": "productType",
|
|
72
|
+
"culture": null,
|
|
73
|
+
"segment": null,
|
|
74
|
+
"editor": "Umbraco.TextBox",
|
|
75
|
+
"value": "Whisky"
|
|
76
|
+
},
|
|
70
77
|
{
|
|
71
78
|
"property": "summary",
|
|
72
79
|
"culture": null,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type PageProps, search } from '@crombolo/crombolo'
|
|
2
|
+
import { Layout } from '../shared/layout.tsx'
|
|
3
|
+
import { Eyebrow } from '../shared/section-intro.tsx'
|
|
4
|
+
|
|
5
|
+
const PER_PAGE = 10
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The pages matching `?q=`, best first, ten at a time. Each result reads its
|
|
9
|
+
* eyebrow and title by the aliases every page type here shares, so a whisky
|
|
10
|
+
* shows as "Single malt — The Harbour 12" rather than by its node name.
|
|
11
|
+
*/
|
|
12
|
+
export default async function SearchPage({ model, nav, request }: PageProps) {
|
|
13
|
+
const url = new URL(request.url)
|
|
14
|
+
const query = url.searchParams.get('q')?.trim() ?? ''
|
|
15
|
+
const page = Math.max(1, Number.parseInt(url.searchParams.get('page') ?? '1', 10) || 1)
|
|
16
|
+
const results = query
|
|
17
|
+
? await search(query, { skip: (page - 1) * PER_PAGE, take: PER_PAGE })
|
|
18
|
+
: { total: 0, items: [] }
|
|
19
|
+
const pages = Math.ceil(results.total / PER_PAGE)
|
|
20
|
+
const link = (n: number) => `${model.url}?q=${encodeURIComponent(query)}&page=${n}`
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Layout model={model} nav={nav}>
|
|
24
|
+
<header class="page-header">
|
|
25
|
+
<Eyebrow>{model.text('eyebrow')}</Eyebrow>
|
|
26
|
+
<h1>{model.text('title')}</h1>
|
|
27
|
+
<search>
|
|
28
|
+
<form class="search-form" action={model.url} method="get">
|
|
29
|
+
<input type="search" name="q" value={query} aria-label="Search the site" />
|
|
30
|
+
<button class="button" type="submit">
|
|
31
|
+
Search
|
|
32
|
+
</button>
|
|
33
|
+
</form>
|
|
34
|
+
</search>
|
|
35
|
+
{query ? (
|
|
36
|
+
<p class="standfirst">
|
|
37
|
+
{results.total === 0
|
|
38
|
+
? `Nothing matches “${query}”.`
|
|
39
|
+
: `${results.total} ${results.total === 1 ? 'page matches' : 'pages match'} “${query}”.`}
|
|
40
|
+
</p>
|
|
41
|
+
) : model.hasValue('standfirst') ? (
|
|
42
|
+
<p class="standfirst">{model.text('standfirst')}</p>
|
|
43
|
+
) : null}
|
|
44
|
+
</header>
|
|
45
|
+
|
|
46
|
+
{results.items.length > 0 ? (
|
|
47
|
+
<section>
|
|
48
|
+
<ol class="search-results">
|
|
49
|
+
{results.items.map((item) => (
|
|
50
|
+
<li>
|
|
51
|
+
<Eyebrow>{item.content.text('eyebrow')}</Eyebrow>
|
|
52
|
+
<h2>
|
|
53
|
+
<a href={item.url}>{item.content.text('title') || item.name}</a>
|
|
54
|
+
</h2>
|
|
55
|
+
<p>
|
|
56
|
+
{item.excerpt.map((segment) =>
|
|
57
|
+
segment.match ? <mark>{segment.text}</mark> : segment.text,
|
|
58
|
+
)}
|
|
59
|
+
</p>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ol>
|
|
63
|
+
{pages > 1 ? (
|
|
64
|
+
<nav class="search-pages" aria-label="Search results pages">
|
|
65
|
+
{page > 1 ? <a href={link(page - 1)}>Previous</a> : <span />}
|
|
66
|
+
<span>
|
|
67
|
+
Page {page} of {pages}
|
|
68
|
+
</span>
|
|
69
|
+
{page < pages ? <a href={link(page + 1)}>Next</a> : <span />}
|
|
70
|
+
</nav>
|
|
71
|
+
) : null}
|
|
72
|
+
</section>
|
|
73
|
+
) : null}
|
|
74
|
+
</Layout>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -11,12 +11,17 @@ import type { Navigation, PublishedContent } from '@crombolo/crombolo'
|
|
|
11
11
|
* There is no hamburger because there is no client-side JavaScript on this
|
|
12
12
|
* site: below the breakpoint the links wrap under the wordmark instead, which
|
|
13
13
|
* `site.css` does with flex-wrap alone.
|
|
14
|
+
*
|
|
15
|
+
* A Search Page among the children is the search box at the end instead of a
|
|
16
|
+
* link: a plain GET form, which needs no script either.
|
|
14
17
|
*/
|
|
15
18
|
export function SiteHeader({ model, nav }: { model: PublishedContent; nav: Navigation }) {
|
|
16
19
|
const home = nav.root()[0]
|
|
17
20
|
if (!home) return null
|
|
18
21
|
const ancestry = model.path.split(',')
|
|
19
|
-
const
|
|
22
|
+
const children = nav.children(home)
|
|
23
|
+
const searchPage = children.find((child) => child.contentType.alias === 'searchPage')
|
|
24
|
+
const links = [home, ...children.filter((child) => child !== searchPage)]
|
|
20
25
|
return (
|
|
21
26
|
<header class="masthead">
|
|
22
27
|
<div class="masthead__inner">
|
|
@@ -38,6 +43,13 @@ export function SiteHeader({ model, nav }: { model: PublishedContent; nav: Navig
|
|
|
38
43
|
)
|
|
39
44
|
})}
|
|
40
45
|
</nav>
|
|
46
|
+
{searchPage ? (
|
|
47
|
+
<search class="masthead__search">
|
|
48
|
+
<form action={searchPage.url} method="get">
|
|
49
|
+
<input type="search" name="q" placeholder="Search" aria-label="Search the site" />
|
|
50
|
+
</form>
|
|
51
|
+
</search>
|
|
52
|
+
) : null}
|
|
41
53
|
</div>
|
|
42
54
|
</header>
|
|
43
55
|
)
|