@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.
Files changed (31) hide show
  1. package/bin/crombolo.ts +689 -23
  2. package/package.json +7 -7
  3. package/src/operations/commands.ts +108 -4
  4. package/src/remote/client.ts +288 -0
  5. package/src/remote/remotes-file.ts +80 -0
  6. package/src/remote/transfer.ts +337 -0
  7. package/templates/basic/bundle/package.json +1 -1
  8. package/templates/demo/harbourstone/bundle/content/bundle.json +11 -4
  9. package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +7 -0
  10. package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +7 -0
  11. package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json +42 -0
  12. package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +7 -0
  13. package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +7 -0
  14. package/templates/demo/harbourstone/bundle/package.json +1 -1
  15. package/templates/demo/harbourstone/files/components/pages/searchPage.tsx +76 -0
  16. package/templates/demo/harbourstone/files/components/shared/site-header.tsx +13 -1
  17. package/templates/demo/harbourstone/files/css/site.css +113 -0
  18. package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +2 -0
  19. package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +2 -0
  20. package/templates/demo/harbourstone/files/schema/content-types/expression.toml +8 -0
  21. package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +5 -1
  22. package/templates/demo/harbourstone/files/schema/content-types/search-page.toml +33 -0
  23. package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +2 -0
  24. package/templates/starter/bundle/content/bundle.json +12 -5
  25. package/templates/starter/bundle/content/nodes/c720ef2a-b5c8-48ec-a504-065e8188d4aa.json +28 -0
  26. package/templates/starter/bundle/package.json +1 -1
  27. package/templates/starter/files/components/pages/searchPage.tsx +57 -0
  28. package/templates/starter/files/components/shared/site-header.tsx +11 -2
  29. package/templates/starter/files/css/site.css +66 -0
  30. package/templates/starter/files/schema/content-types/content-page.toml +4 -1
  31. 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
+ }
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.13.2"
9
+ "@crombolo/crombolo": ">=0.14.4"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "basic",
@@ -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-dda870bf7e8fadb391bcadc9e75a5721cfe209a9e8aa794df2801e0b17d8e958",
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": "9c729977ea20bcaca36022f92fd57b76321825b82d817f7e688bd8dc6b7ba289"
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": 8
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": []
@@ -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,
@@ -67,6 +67,13 @@
67
67
  "editor": "Umbraco.TextBox",
68
68
  "value": "House malt"
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,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
+ }
@@ -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,
@@ -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,
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.13.2"
9
+ "@crombolo/crombolo": ">=0.14.4"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "demo-harbourstone",
@@ -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 links = [home, ...nav.children(home)]
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
  )