@crombolo/cli 0.13.2 → 0.14.1

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 (28) hide show
  1. package/bin/crombolo.ts +289 -22
  2. package/package.json +7 -7
  3. package/src/operations/commands.ts +108 -4
  4. package/templates/basic/bundle/package.json +1 -1
  5. package/templates/demo/harbourstone/bundle/content/bundle.json +11 -4
  6. package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +7 -0
  7. package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +7 -0
  8. package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json +42 -0
  9. package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +7 -0
  10. package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +7 -0
  11. package/templates/demo/harbourstone/bundle/package.json +1 -1
  12. package/templates/demo/harbourstone/files/components/pages/searchPage.tsx +76 -0
  13. package/templates/demo/harbourstone/files/components/shared/site-header.tsx +13 -1
  14. package/templates/demo/harbourstone/files/css/site.css +113 -0
  15. package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +2 -0
  16. package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +2 -0
  17. package/templates/demo/harbourstone/files/schema/content-types/expression.toml +8 -0
  18. package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +5 -1
  19. package/templates/demo/harbourstone/files/schema/content-types/search-page.toml +33 -0
  20. package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +2 -0
  21. package/templates/starter/bundle/content/bundle.json +12 -5
  22. package/templates/starter/bundle/content/nodes/c720ef2a-b5c8-48ec-a504-065e8188d4aa.json +28 -0
  23. package/templates/starter/bundle/package.json +1 -1
  24. package/templates/starter/files/components/pages/searchPage.tsx +57 -0
  25. package/templates/starter/files/components/shared/site-header.tsx +11 -2
  26. package/templates/starter/files/css/site.css +66 -0
  27. package/templates/starter/files/schema/content-types/content-page.toml +4 -1
  28. package/templates/starter/files/schema/content-types/search-page.toml +21 -0
@@ -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.1"
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
  )
@@ -162,6 +162,27 @@ main > article,
162
162
  color: var(--brass-deep);
163
163
  }
164
164
 
165
+ /* The search box sits after the links, as small and quiet as they are. */
166
+ .masthead__search input {
167
+ width: 9rem;
168
+ padding: 0.35rem 0.6rem;
169
+ border: 1px solid var(--line);
170
+ border-radius: 0;
171
+ background: var(--card);
172
+ color: var(--ink);
173
+ font: inherit;
174
+ font-size: 0.875rem;
175
+ transition:
176
+ border-color var(--ease),
177
+ width var(--ease);
178
+ }
179
+
180
+ .masthead__search input:focus-visible {
181
+ outline: none;
182
+ width: 13rem;
183
+ border-color: var(--brass);
184
+ }
185
+
165
186
  .site-footer {
166
187
  margin-top: 5rem;
167
188
  border-top: 1px solid var(--line);
@@ -718,3 +739,95 @@ main > article,
718
739
  padding-block: 0;
719
740
  }
720
741
  }
742
+
743
+ /* -------------------------------------------------------------- search */
744
+
745
+ .search-form {
746
+ display: flex;
747
+ flex-wrap: wrap;
748
+ gap: 0.75rem;
749
+ align-items: stretch;
750
+ margin-top: 2rem;
751
+ max-width: 36rem;
752
+ }
753
+
754
+ .search-form input {
755
+ flex: 1;
756
+ min-width: 12rem;
757
+ padding: 0.75rem;
758
+ border: 1px solid var(--line);
759
+ border-radius: 0;
760
+ background: var(--card);
761
+ color: var(--ink);
762
+ font: inherit;
763
+ font-size: 1rem;
764
+ transition: border-color var(--ease);
765
+ }
766
+
767
+ .search-form input:focus-visible {
768
+ outline: none;
769
+ border-color: var(--brass);
770
+ }
771
+
772
+ .search-form .button {
773
+ margin-top: 0;
774
+ background: none;
775
+ cursor: pointer;
776
+ font-family: inherit;
777
+ }
778
+
779
+ .search-results {
780
+ margin: 0;
781
+ padding: 0;
782
+ max-width: 44rem;
783
+ list-style: none;
784
+ }
785
+
786
+ .search-results li {
787
+ padding-block: 1.75rem;
788
+ border-top: 1px solid var(--line);
789
+ }
790
+
791
+ .search-results h2 {
792
+ margin: 0.5rem 0 0;
793
+ font-size: 1.5rem;
794
+ }
795
+
796
+ .search-results h2 a {
797
+ text-decoration: none;
798
+ transition: color var(--ease);
799
+ }
800
+
801
+ .search-results h2 a:hover {
802
+ color: var(--brass-deep);
803
+ }
804
+
805
+ .search-results p {
806
+ margin: 0.75rem 0 0;
807
+ color: var(--muted);
808
+ }
809
+
810
+ .search-results mark {
811
+ background: none;
812
+ color: var(--ink);
813
+ font-weight: 500;
814
+ }
815
+
816
+ .search-pages {
817
+ display: flex;
818
+ justify-content: space-between;
819
+ align-items: baseline;
820
+ max-width: 44rem;
821
+ padding-top: 1.75rem;
822
+ border-top: 1px solid var(--line);
823
+ font-size: 0.75rem;
824
+ font-weight: 500;
825
+ letter-spacing: 0.18em;
826
+ text-transform: uppercase;
827
+ color: var(--muted);
828
+ }
829
+
830
+ nav.search-pages a {
831
+ color: var(--brass-deep);
832
+ text-decoration: none;
833
+ }
@@ -19,6 +19,7 @@ key = "753d8c52-4949-4e92-a76a-fa02da0fd199"
19
19
  alias = "eyebrow"
20
20
  name = "Eyebrow"
21
21
  type = "textstring"
22
+ site-search = "low"
22
23
 
23
24
  [[tab.property]]
24
25
  key = "1e8f3a9d-5bdf-4a01-a6c7-cc4a055fae22"
@@ -26,6 +27,7 @@ alias = "title"
26
27
  name = "Title"
27
28
  type = "textstring"
28
29
  mandatory = true
30
+ site-search = "high"
29
31
 
30
32
  [[tab.property]]
31
33
  key = "cf4561c0-82bb-4ad5-9f88-bd3fbd210b7c"
@@ -17,6 +17,7 @@ alias = "eyebrow"
17
17
  name = "Eyebrow"
18
18
  description = "The small line above the heading"
19
19
  type = "textstring"
20
+ site-search = "low"
20
21
 
21
22
  [[tab.property]]
22
23
  key = "4501d20d-3b78-40a4-97e4-dd25b2d8535a"
@@ -24,6 +25,7 @@ alias = "title"
24
25
  name = "Title"
25
26
  type = "textstring"
26
27
  mandatory = true
28
+ site-search = "high"
27
29
 
28
30
  [[tab.property]]
29
31
  key = "33199ac7-8128-4dc4-bae1-554b2a622447"
@@ -16,6 +16,7 @@ alias = "title"
16
16
  name = "Title"
17
17
  type = "textstring"
18
18
  mandatory = true
19
+ site-search = "high"
19
20
 
20
21
  [[tab.property]]
21
22
  key = "25738367-ed14-4985-9843-04716417c88a"
@@ -24,6 +25,13 @@ name = "Kind"
24
25
  description = "House malt, Lightly peated, Sherry matured, Cask strength"
25
26
  type = "textstring"
26
27
 
28
+ [[tab.property]]
29
+ key = "96f868bf-a368-4457-843a-1f38accf6cc1"
30
+ alias = "productType"
31
+ name = "Product type"
32
+ description = "What the bottling is, e.g. Whisky"
33
+ type = "textstring"
34
+
27
35
  [[tab.property]]
28
36
  key = "f4238bf1-3d9d-4dbc-b6f4-9800e1dfdc79"
29
37
  alias = "summary"
@@ -5,7 +5,7 @@ name = "Home Page"
5
5
  description = "The site's landing page"
6
6
  icon = "icon-home"
7
7
  allow-at-root = true
8
- allow-children = ["contentPage", "whiskyList", "contactPage"]
8
+ allow-children = ["contentPage", "whiskyList", "contactPage", "searchPage"]
9
9
  components = ["pages/homePage"]
10
10
  default-component = "pages/homePage"
11
11
 
@@ -18,6 +18,7 @@ alias = "eyebrow"
18
18
  name = "Eyebrow"
19
19
  description = "The small line above the heading"
20
20
  type = "textstring"
21
+ site-search = "low"
21
22
 
22
23
  [[tab.property]]
23
24
  key = "be74a0df-3e9d-47f3-b60e-778a89682a80"
@@ -26,6 +27,7 @@ name = "Title"
26
27
  description = "Shown as the page heading"
27
28
  type = "textstring"
28
29
  mandatory = true
30
+ site-search = "high"
29
31
 
30
32
  [[tab.property]]
31
33
  key = "a540c93a-228b-49f6-931a-be1b986bedd1"
@@ -46,6 +48,7 @@ alias = "heroCtaLabel"
46
48
  name = "Hero button"
47
49
  description = "Links to the range. Leave empty for no button."
48
50
  type = "textstring"
51
+ site-search = false
49
52
 
50
53
  [[tab]]
51
54
  name = "House style"
@@ -102,3 +105,4 @@ alias = "rangeCtaLabel"
102
105
  name = "Button"
103
106
  description = "Links to the range. Leave empty for no button."
104
107
  type = "textstring"
108
+ site-search = false
@@ -0,0 +1,33 @@
1
+ [content-type]
2
+ key = "4308082e-5024-47fc-950d-1ee93a3e304f"
3
+ alias = "searchPage"
4
+ name = "Search Page"
5
+ description = "Lists the pages matching what a visitor searched for"
6
+ icon = "icon-search"
7
+ components = ["pages/searchPage"]
8
+ default-component = "pages/searchPage"
9
+ site-search = false
10
+
11
+ [[tab]]
12
+ name = "Header"
13
+
14
+ [[tab.property]]
15
+ key = "3a5d2f03-8bf6-41e2-9971-3848df983f0a"
16
+ alias = "eyebrow"
17
+ name = "Eyebrow"
18
+ description = "The small line above the heading"
19
+ type = "textstring"
20
+
21
+ [[tab.property]]
22
+ key = "e5be09c2-12bd-4647-a6c9-2549459d97b4"
23
+ alias = "title"
24
+ name = "Title"
25
+ type = "textstring"
26
+ mandatory = true
27
+
28
+ [[tab.property]]
29
+ key = "16eab79f-e753-48ca-a3f8-7e326a3f513f"
30
+ alias = "standfirst"
31
+ name = "Standfirst"
32
+ description = "A sentence under the heading, shown before anyone has searched"
33
+ type = "textarea"
@@ -19,6 +19,7 @@ key = "13785a42-6d0a-4152-89ec-c765db766547"
19
19
  alias = "eyebrow"
20
20
  name = "Eyebrow"
21
21
  type = "textstring"
22
+ site-search = "low"
22
23
 
23
24
  [[tab.property]]
24
25
  key = "727804c0-7d4a-43d9-a473-71c9e27397c0"
@@ -26,6 +27,7 @@ alias = "title"
26
27
  name = "Title"
27
28
  type = "textstring"
28
29
  mandatory = true
30
+ site-search = "high"
29
31
 
30
32
  [[tab.property]]
31
33
  key = "9c83bcea-a70e-4a3f-8185-ad04ddec55f1"
@@ -3,19 +3,20 @@
3
3
  "id": "da9fd74e-b964-44b4-8d61-afa7f65a28d2",
4
4
  "createdAt": "2026-10-01T00:00:00.000Z",
5
5
  "createdBy": "bun run build:template",
6
- "integrity": "sha256-3ef21d4b047d85bfef8aeaac90255348bf636ba6ff3c0d0e155a06a2225269de",
6
+ "integrity": "sha256-c8b50fa21bb3bed8ecf30380427ef65894dafb97734e9bf17ed465080cba7866",
7
7
  "snapshot": "published",
8
8
  "provenance": {
9
9
  "siteName": "Starter",
10
10
  "schemaVersion": "1.0.0",
11
11
  "schemaRevision": "0",
12
- "schemaHash": "81d3ebeb2c46ec1a17f0bf253e1bf711c47390ac49eb3f32a60b23e3ace89b16"
12
+ "schemaHash": "1d47f00fd4feb3085e530673fceb33320ddb1c604fbb385c73e631eaace62a73"
13
13
  },
14
14
  "selector": {
15
15
  "roots": [
16
16
  "64d95359-d91a-4050-b440-3e5124a74999",
17
17
  "634dd948-cf83-4cad-9b7b-7c938b33da76",
18
- "d2997ebb-36ca-47cb-bbc8-31fefca787c0"
18
+ "d2997ebb-36ca-47cb-bbc8-31fefca787c0",
19
+ "c720ef2a-b5c8-48ec-a504-065e8188d4aa"
19
20
  ],
20
21
  "descendants": true,
21
22
  "asGiven": [
@@ -23,12 +24,13 @@
23
24
  ]
24
25
  },
25
26
  "counts": {
26
- "document": 3
27
+ "document": 4
27
28
  },
28
29
  "dependencies": {
29
30
  "carried": [
30
31
  "634dd948-cf83-4cad-9b7b-7c938b33da76",
31
32
  "64d95359-d91a-4050-b440-3e5124a74999",
33
+ "c720ef2a-b5c8-48ec-a504-065e8188d4aa",
32
34
  "d2997ebb-36ca-47cb-bbc8-31fefca787c0"
33
35
  ],
34
36
  "expected": [],
@@ -37,10 +39,15 @@
37
39
  {
38
40
  "key": "8491ffa4-9b2f-48b0-a3c0-f40c1034216e",
39
41
  "alias": "contentPage"
42
+ },
43
+ {
44
+ "key": "938c7f88-8df2-45d2-a4a2-49ecb3e68db7",
45
+ "alias": "searchPage"
40
46
  }
41
47
  ],
42
48
  "components": [
43
- "pages/contentPage"
49
+ "pages/contentPage",
50
+ "pages/searchPage"
44
51
  ],
45
52
  "languages": []
46
53
  }
@@ -0,0 +1,28 @@
1
+ {
2
+ "key": "c720ef2a-b5c8-48ec-a504-065e8188d4aa",
3
+ "kind": "document",
4
+ "contentType": {
5
+ "key": "938c7f88-8df2-45d2-a4a2-49ecb3e68db7",
6
+ "alias": "searchPage"
7
+ },
8
+ "parent": "64d95359-d91a-4050-b440-3e5124a74999",
9
+ "sortOrder": 2,
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": "title",
22
+ "culture": null,
23
+ "segment": null,
24
+ "editor": "Umbraco.TextBox",
25
+ "value": "Search"
26
+ }
27
+ ]
28
+ }
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.13.2"
9
+ "@crombolo/crombolo": ">=0.14.1"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "starter",
@@ -0,0 +1,57 @@
1
+ import { type PageProps, search } from '@crombolo/crombolo'
2
+ import { Layout } from '../shared/layout.tsx'
3
+
4
+ const PER_PAGE = 10
5
+
6
+ /** The pages matching `?q=`, best first, ten at a time, each with the text around the match. */
7
+ export default async function SearchPage(props: PageProps) {
8
+ const { model, request } = props
9
+ const url = new URL(request.url)
10
+ const query = url.searchParams.get('q')?.trim() ?? ''
11
+ const page = Math.max(1, Number.parseInt(url.searchParams.get('page') ?? '1', 10) || 1)
12
+ const results = query
13
+ ? await search(query, { skip: (page - 1) * PER_PAGE, take: PER_PAGE })
14
+ : { total: 0, items: [] }
15
+ const pages = Math.ceil(results.total / PER_PAGE)
16
+ const link = (n: number) => `${model.url}?q=${encodeURIComponent(query)}&page=${n}`
17
+
18
+ return (
19
+ <Layout {...props}>
20
+ <h1>{model.text('title') || model.name}</h1>
21
+ <search>
22
+ <form class="search-form" action={model.url} method="get">
23
+ <input type="search" name="q" value={query} aria-label="Search the site" />
24
+ <button type="submit">Search</button>
25
+ </form>
26
+ </search>
27
+ {query ? (
28
+ <p class="search-summary">
29
+ {results.total === 0
30
+ ? `Nothing matches “${query}”.`
31
+ : `${results.total} ${results.total === 1 ? 'page matches' : 'pages match'} “${query}”.`}
32
+ </p>
33
+ ) : null}
34
+ <ol class="search-results">
35
+ {results.items.map((item) => (
36
+ <li>
37
+ <a href={item.url}>{item.content.text('title') || item.name}</a>
38
+ <p>
39
+ {item.excerpt.map((segment) =>
40
+ segment.match ? <mark>{segment.text}</mark> : segment.text,
41
+ )}
42
+ </p>
43
+ </li>
44
+ ))}
45
+ </ol>
46
+ {pages > 1 ? (
47
+ <nav class="search-pages" aria-label="Search results pages">
48
+ {page > 1 ? <a href={link(page - 1)}>Previous</a> : null}
49
+ <span>
50
+ Page {page} of {pages}
51
+ </span>
52
+ {page < pages ? <a href={link(page + 1)}>Next</a> : null}
53
+ </nav>
54
+ ) : null}
55
+ </Layout>
56
+ )
57
+ }
@@ -2,7 +2,7 @@ import type { Navigation, PublishedContent } from '@crombolo/crombolo'
2
2
 
3
3
  /**
4
4
  * The site name, from the config, and the primary nav: the home page followed by its children, so
5
- * adding a page in the backoffice adds it here.
5
+ * adding a page in the backoffice adds it here. A Search Page among them is the search box instead.
6
6
  */
7
7
  export function SiteHeader({
8
8
  model,
@@ -16,7 +16,9 @@ export function SiteHeader({
16
16
  const home = nav.root()[0]
17
17
  if (!home) return null
18
18
  const ancestry = model.path.split(',')
19
- const links = [home, ...nav.children(home, { visibleOnly: true })]
19
+ const children = nav.children(home, { visibleOnly: true })
20
+ const searchPage = children.find((child) => child.contentType.alias === 'searchPage')
21
+ const links = [home, ...children.filter((child) => child !== searchPage)]
20
22
  return (
21
23
  <header class="site-header">
22
24
  <div class="container site-header__inner">
@@ -34,6 +36,13 @@ export function SiteHeader({
34
36
  )
35
37
  })}
36
38
  </nav>
39
+ {searchPage ? (
40
+ <search>
41
+ <form class="site-search" action={searchPage.url} method="get">
42
+ <input type="search" name="q" placeholder="Search" aria-label="Search the site" />
43
+ </form>
44
+ </search>
45
+ ) : null}
37
46
  </div>
38
47
  </header>
39
48
  )