@crombolo/cli 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/bin/crombolo.ts +2183 -0
- package/package.json +42 -4
- package/src/index.ts +7 -0
- package/src/operations/commands.ts +645 -0
- package/src/templates.ts +309 -0
- package/templates/basic/bundle/content/bundle.json +45 -0
- package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +38 -0
- package/templates/basic/bundle/package.json +16 -0
- package/templates/basic/files/components/homePage.tsx +22 -0
- package/templates/basic/files/schema/content-types/home-page.toml +28 -0
- package/templates/basic/files/schema/schema.toml +2 -0
- package/templates/basic/template.json +4 -0
- package/templates/demo/harbourstone/CREDITS.md +42 -0
- package/templates/demo/harbourstone/bundle/content/blobs/180cc8ef/distillery-from-the-cliff-path.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/68c9cfdf/bottle-brine-and-kiln.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/6be45535/bottle-oloroso-pier.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/6d8d6398/bottle-the-cove.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/7b4713f8/copper-pot-stills.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/c114b7d8/bottle-neap-tide.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/fd337567/casks-in-the-sea-facing-warehouse.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/bundle.json +197 -0
- package/templates/demo/harbourstone/bundle/content/nodes/069cc3c5-c9f5-4792-90c9-e2bfcc4855b6.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/13788fe7-1a45-4b48-8ae4-f4227e154e1e.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/180cc8ef-caf1-4316-b6a7-e3b5aee41ffd.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/22144db2-e214-4006-a0bb-53b5bd121fd6.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/26e87610-e2d6-4b47-bf92-3967f7186907.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/2caa099b-3884-472d-9cea-797a0f0c376f.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/2efbdb5b-7888-4a49-bca5-c0f85b76e472.json +42 -0
- package/templates/demo/harbourstone/bundle/content/nodes/2fe03891-1294-4344-8e24-47e8b212e5b0.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/38b009f7-03c0-4fda-b5c7-843ad6044e9c.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/3d312cc8-2937-40e1-ba14-fd781c9a862d.json +95 -0
- package/templates/demo/harbourstone/bundle/content/nodes/3dc30fc1-973a-4217-b26f-24902210be17.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +101 -0
- package/templates/demo/harbourstone/bundle/content/nodes/483a0d3b-a10c-4c9d-8c81-d9607d37e968.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/4b760c0b-b83a-4eee-aa29-c225cfabf19b.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/4e5c7f3f-54cb-4328-ad61-bc4c2736b890.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/62f479ff-0875-453e-99ae-3bf1bec5e3c8.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/68c9cfdf-6b40-47da-a1d2-17256a6cc6db.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +101 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6be45535-83e5-4469-acf0-4127016721ae.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6d8d6398-738f-4f38-ad3c-e5d6e9a971fe.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/7b4713f8-d917-457d-a9d8-7dd01a3617b4.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/88ec1185-53b5-439a-a2ce-3f56603be4ff.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/97469df2-107c-4010-8c14-4de88f735529.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a2eb439f-71d9-4ea6-89d7-c5bc72618281.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +101 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a5e36a18-a2ef-4eb5-85b4-7e12b21cca06.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/b1876d3a-50a7-4358-84bf-a0899932ffa9.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/b4e38bbd-c1a3-431a-b7a9-5868aa32993a.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/c114b7d8-0138-404b-9d8f-a39d992577a2.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/cb4c569e-c28a-43b6-a4d7-d33bb544e973.json +136 -0
- package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +101 -0
- package/templates/demo/harbourstone/bundle/content/nodes/d60ef12b-0f0a-4db5-9763-21fa52b8d4da.json +150 -0
- package/templates/demo/harbourstone/bundle/content/nodes/d7f13222-d060-4496-b86d-bef21da963c1.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/f36dd4a9-927c-4ca3-a790-c9c859ac7302.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/fd337567-df67-41ca-ae94-9d84d8d6042f.json +60 -0
- package/templates/demo/harbourstone/bundle/package.json +16 -0
- package/templates/demo/harbourstone/files/components/pages/contactPage.tsx +36 -0
- package/templates/demo/harbourstone/files/components/pages/contentPage.tsx +54 -0
- package/templates/demo/harbourstone/files/components/pages/expression.tsx +33 -0
- package/templates/demo/harbourstone/files/components/pages/homePage.tsx +71 -0
- package/templates/demo/harbourstone/files/components/pages/whiskyList.tsx +19 -0
- package/templates/demo/harbourstone/files/components/shared/bottling.tsx +59 -0
- package/templates/demo/harbourstone/files/components/shared/highlights.tsx +39 -0
- package/templates/demo/harbourstone/files/components/shared/layout.tsx +49 -0
- package/templates/demo/harbourstone/files/components/shared/section-intro.tsx +40 -0
- package/templates/demo/harbourstone/files/components/shared/site-footer.tsx +14 -0
- package/templates/demo/harbourstone/files/components/shared/site-header.tsx +57 -0
- package/templates/demo/harbourstone/files/css/site.css +720 -0
- package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +75 -0
- package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +89 -0
- package/templates/demo/harbourstone/files/schema/content-types/expression.toml +78 -0
- package/templates/demo/harbourstone/files/schema/content-types/highlight.toml +27 -0
- package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +104 -0
- package/templates/demo/harbourstone/files/schema/content-types/tasting-note.toml +26 -0
- package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +35 -0
- package/templates/demo/harbourstone/files/schema/data-types/highlight-picker.toml +10 -0
- package/templates/demo/harbourstone/files/schema/data-types/tasting-note-picker.toml +11 -0
- package/templates/demo/harbourstone/files/schema/forms/contact.toml +119 -0
- package/templates/demo/harbourstone/files/schema/schema.toml +2 -0
- package/templates/demo/harbourstone/template.json +10 -0
- package/templates/starter/bundle/content/bundle.json +49 -0
- package/templates/starter/bundle/content/nodes/634dd948-cf83-4cad-9b7b-7c938b33da76.json +62 -0
- package/templates/starter/bundle/content/nodes/64d95359-d91a-4050-b440-3e5124a74999.json +62 -0
- package/templates/starter/bundle/content/nodes/d2997ebb-36ca-47cb-bbc8-31fefca787c0.json +45 -0
- package/templates/starter/bundle/package.json +16 -0
- package/templates/starter/files/components/pages/contentPage.tsx +25 -0
- package/templates/starter/files/components/shared/layout.tsx +24 -0
- package/templates/starter/files/components/shared/site-footer.tsx +9 -0
- package/templates/starter/files/components/shared/site-header.tsx +40 -0
- package/templates/starter/files/css/site.css +117 -0
- package/templates/starter/files/schema/content-types/content-page.toml +51 -0
- package/templates/starter/files/schema/schema.toml +2 -0
- package/templates/starter/template.json +4 -0
- package/README.md +0 -3
package/templates/demo/harbourstone/bundle/content/nodes/f36dd4a9-927c-4ca3-a790-c9c859ac7302.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "f36dd4a9-927c-4ca3-a790-c9c859ac7302",
|
|
3
|
+
"kind": "element",
|
|
4
|
+
"contentType": {
|
|
5
|
+
"key": "55138391-faf2-4795-9ed6-43d581d350a4",
|
|
6
|
+
"alias": "tastingNote"
|
|
7
|
+
},
|
|
8
|
+
"parent": null,
|
|
9
|
+
"sortOrder": 16,
|
|
10
|
+
"template": null,
|
|
11
|
+
"variants": [
|
|
12
|
+
{
|
|
13
|
+
"culture": null,
|
|
14
|
+
"segment": null,
|
|
15
|
+
"name": "Finish — Dry and long. Tannin held in…",
|
|
16
|
+
"published": true
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"values": [
|
|
20
|
+
{
|
|
21
|
+
"property": "aspect",
|
|
22
|
+
"culture": null,
|
|
23
|
+
"segment": null,
|
|
24
|
+
"editor": "Umbraco.TextBox",
|
|
25
|
+
"value": "Finish"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"property": "note",
|
|
29
|
+
"culture": null,
|
|
30
|
+
"segment": null,
|
|
31
|
+
"editor": "Umbraco.TextArea",
|
|
32
|
+
"value": "Dry and long. Tannin held in check by brine."
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
package/templates/demo/harbourstone/bundle/content/nodes/fd337567-df67-41ca-ae94-9d84d8d6042f.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "fd337567-df67-41ca-ae94-9d84d8d6042f",
|
|
3
|
+
"kind": "media",
|
|
4
|
+
"contentType": {
|
|
5
|
+
"key": "cc07b313-0843-4aa8-bbda-871c8da728c8",
|
|
6
|
+
"alias": "Image"
|
|
7
|
+
},
|
|
8
|
+
"parent": null,
|
|
9
|
+
"sortOrder": 2,
|
|
10
|
+
"template": null,
|
|
11
|
+
"variants": [
|
|
12
|
+
{
|
|
13
|
+
"culture": null,
|
|
14
|
+
"segment": null,
|
|
15
|
+
"name": "Casks in the sea-facing warehouse",
|
|
16
|
+
"published": false
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"values": [
|
|
20
|
+
{
|
|
21
|
+
"property": "umbracoBytes",
|
|
22
|
+
"culture": null,
|
|
23
|
+
"segment": null,
|
|
24
|
+
"editor": "Umbraco.Label",
|
|
25
|
+
"value": "113141"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"property": "umbracoExtension",
|
|
29
|
+
"culture": null,
|
|
30
|
+
"segment": null,
|
|
31
|
+
"editor": "Umbraco.Label",
|
|
32
|
+
"value": "jpg"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"property": "umbracoFile",
|
|
36
|
+
"culture": null,
|
|
37
|
+
"segment": null,
|
|
38
|
+
"editor": "Umbraco.ImageCropper",
|
|
39
|
+
"value": {
|
|
40
|
+
"src": "/media/fd337567/casks-in-the-sea-facing-warehouse.jpg",
|
|
41
|
+
"crops": [],
|
|
42
|
+
"focalPoint": null
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"property": "umbracoHeight",
|
|
47
|
+
"culture": null,
|
|
48
|
+
"segment": null,
|
|
49
|
+
"editor": "Umbraco.Label",
|
|
50
|
+
"value": 800
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"property": "umbracoWidth",
|
|
54
|
+
"culture": null,
|
|
55
|
+
"segment": null,
|
|
56
|
+
"editor": "Umbraco.Label",
|
|
57
|
+
"value": 1200
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "demo-harbourstone",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"files": [
|
|
6
|
+
"content"
|
|
7
|
+
],
|
|
8
|
+
"peerDependencies": {
|
|
9
|
+
"@crombolo/crombolo": "^0.10.0"
|
|
10
|
+
},
|
|
11
|
+
"crombolo": {
|
|
12
|
+
"id": "demo-harbourstone",
|
|
13
|
+
"name": "Harbourstone Distillery",
|
|
14
|
+
"content": "content"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Form, type PageProps, type SchemaForm } from '@crombolo/crombolo'
|
|
2
|
+
import { Layout } from '../shared/layout.tsx'
|
|
3
|
+
import { Eyebrow, PageHeader } from '../shared/section-intro.tsx'
|
|
4
|
+
|
|
5
|
+
export default function ContactPage({ model, nav, submission }: PageProps) {
|
|
6
|
+
// A `formPicker` property arrives as the definition itself, so there is
|
|
7
|
+
// nothing to look up here. `value()` is untyped — `crombolo generate` is what
|
|
8
|
+
// types a model — so the cast is what a hand-written view pays.
|
|
9
|
+
//
|
|
10
|
+
// `submission` is what just happened to a post on this page: passing it
|
|
11
|
+
// through is what makes a refused submission come back with its errors inside
|
|
12
|
+
// this layout rather than on a bare page.
|
|
13
|
+
const form = model.value('enquiryForm') as SchemaForm | null
|
|
14
|
+
const image = model.media('asideImage')[0]
|
|
15
|
+
return (
|
|
16
|
+
<Layout model={model} nav={nav}>
|
|
17
|
+
<PageHeader model={model} />
|
|
18
|
+
<section class="contact">
|
|
19
|
+
<div class="contact__form">
|
|
20
|
+
{form ? <Form form={form} submission={submission} /> : null}
|
|
21
|
+
</div>
|
|
22
|
+
<aside class="contact__aside">
|
|
23
|
+
<Eyebrow>{model.text('asideEyebrow')}</Eyebrow>
|
|
24
|
+
<h2>{model.text('asideHeading')}</h2>
|
|
25
|
+
<div
|
|
26
|
+
class="prose"
|
|
27
|
+
setInnerHTML={{ __html: model.text('asideBody'), dangerously: true }}
|
|
28
|
+
/>
|
|
29
|
+
{image ? (
|
|
30
|
+
<img src={image.cropUrl({ width: 1200, height: 800 })} alt={image.name} />
|
|
31
|
+
) : null}
|
|
32
|
+
</aside>
|
|
33
|
+
</section>
|
|
34
|
+
</Layout>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PageProps } from '@crombolo/crombolo'
|
|
2
|
+
import { HighlightRows, highlightsOf } from '../shared/highlights.tsx'
|
|
3
|
+
import { Layout } from '../shared/layout.tsx'
|
|
4
|
+
import { PageHeader, SectionIntro } from '../shared/section-intro.tsx'
|
|
5
|
+
|
|
6
|
+
export default function ContentPage({ model, nav }: PageProps) {
|
|
7
|
+
const image = model.media('heroImage')[0]
|
|
8
|
+
const highlights = highlightsOf(model, 'highlights')
|
|
9
|
+
const highlightsImage = model.media('highlightsImage')[0]
|
|
10
|
+
return (
|
|
11
|
+
<Layout model={model} nav={nav}>
|
|
12
|
+
<PageHeader model={model} />
|
|
13
|
+
|
|
14
|
+
<section class="split split--image-first">
|
|
15
|
+
{image ? (
|
|
16
|
+
<img
|
|
17
|
+
class="split__image"
|
|
18
|
+
src={image.cropUrl({ width: 1200, height: 800 })}
|
|
19
|
+
alt={image.name}
|
|
20
|
+
/>
|
|
21
|
+
) : null}
|
|
22
|
+
<div>
|
|
23
|
+
<SectionIntro
|
|
24
|
+
eyebrow={model.text('sectionEyebrow')}
|
|
25
|
+
heading={model.text('sectionHeading')}
|
|
26
|
+
/>
|
|
27
|
+
<div class="prose" setInnerHTML={{ __html: model.text('bodyText'), dangerously: true }} />
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
|
|
31
|
+
{highlights.length > 0 ? (
|
|
32
|
+
<section class="band band--dark">
|
|
33
|
+
<div class="split">
|
|
34
|
+
<div>
|
|
35
|
+
<SectionIntro
|
|
36
|
+
eyebrow={model.text('highlightsEyebrow')}
|
|
37
|
+
heading={model.text('highlightsHeading')}
|
|
38
|
+
tone="light"
|
|
39
|
+
/>
|
|
40
|
+
<HighlightRows items={highlights} />
|
|
41
|
+
</div>
|
|
42
|
+
{highlightsImage ? (
|
|
43
|
+
<img
|
|
44
|
+
class="split__image"
|
|
45
|
+
src={highlightsImage.cropUrl({ width: 1200, height: 800 })}
|
|
46
|
+
alt={highlightsImage.name}
|
|
47
|
+
/>
|
|
48
|
+
) : null}
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
) : null}
|
|
52
|
+
</Layout>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PageProps, PublishedElement } from '@crombolo/crombolo'
|
|
2
|
+
import { specOf, TastingNotes } from '../shared/bottling.tsx'
|
|
3
|
+
import { Layout } from '../shared/layout.tsx'
|
|
4
|
+
import { Eyebrow } from '../shared/section-intro.tsx'
|
|
5
|
+
|
|
6
|
+
export default function Expression({ model, nav }: PageProps) {
|
|
7
|
+
const bottle = model.media('bottleImage')[0]
|
|
8
|
+
const notes = (model.value('tastingNotes') ?? []) as PublishedElement[]
|
|
9
|
+
const range = nav.parent(model)
|
|
10
|
+
return (
|
|
11
|
+
<Layout model={model} nav={nav}>
|
|
12
|
+
<article class="bottling">
|
|
13
|
+
<div class="bottling__image">
|
|
14
|
+
{bottle ? (
|
|
15
|
+
<img src={bottle.cropUrl({ width: 760, height: 1140 })} alt={bottle.name} />
|
|
16
|
+
) : null}
|
|
17
|
+
</div>
|
|
18
|
+
<div class="bottling__text">
|
|
19
|
+
<Eyebrow>{specOf(model)}</Eyebrow>
|
|
20
|
+
<h1>{model.text('title')}</h1>
|
|
21
|
+
{model.hasValue('summary') ? <p class="standfirst">{model.text('summary')}</p> : null}
|
|
22
|
+
<div class="prose" setInnerHTML={{ __html: model.text('bodyText'), dangerously: true }} />
|
|
23
|
+
<TastingNotes notes={notes} />
|
|
24
|
+
{range ? (
|
|
25
|
+
<p class="back">
|
|
26
|
+
<a href={range.url}>Back to {range.text('title')}</a>
|
|
27
|
+
</p>
|
|
28
|
+
) : null}
|
|
29
|
+
</div>
|
|
30
|
+
</article>
|
|
31
|
+
</Layout>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { PageProps } from '@crombolo/crombolo'
|
|
2
|
+
import { BottlingCard } from '../shared/bottling.tsx'
|
|
3
|
+
import { Figures, highlightsOf } from '../shared/highlights.tsx'
|
|
4
|
+
import { Layout } from '../shared/layout.tsx'
|
|
5
|
+
import { Eyebrow, SectionIntro } from '../shared/section-intro.tsx'
|
|
6
|
+
|
|
7
|
+
export default function HomePage({ model, nav }: PageProps) {
|
|
8
|
+
const hero = model.media('heroImage')[0]
|
|
9
|
+
const styleImage = model.media('styleImage')[0]
|
|
10
|
+
// The range is whichever child is the list, rather than a hard-coded path:
|
|
11
|
+
// rename or move it in the backoffice and these two sections follow.
|
|
12
|
+
const range = nav.children(model).find((child) => child.contentType.alias === 'whiskyList')
|
|
13
|
+
const bottlings = range ? nav.children(range) : []
|
|
14
|
+
return (
|
|
15
|
+
<Layout model={model} nav={nav}>
|
|
16
|
+
<section class="hero">
|
|
17
|
+
{hero ? (
|
|
18
|
+
<img
|
|
19
|
+
class="hero__image"
|
|
20
|
+
src={hero.cropUrl({ width: 1600, height: 900 })}
|
|
21
|
+
alt={hero.name}
|
|
22
|
+
/>
|
|
23
|
+
) : null}
|
|
24
|
+
<div class="hero__text">
|
|
25
|
+
<Eyebrow tone="light">{model.text('eyebrow')}</Eyebrow>
|
|
26
|
+
<h1>{model.text('title')}</h1>
|
|
27
|
+
<p class="strapline">{model.text('strapline')}</p>
|
|
28
|
+
{range && model.hasValue('heroCtaLabel') ? (
|
|
29
|
+
<a class="button button--light" href={range.url}>
|
|
30
|
+
{model.text('heroCtaLabel')}
|
|
31
|
+
</a>
|
|
32
|
+
) : null}
|
|
33
|
+
</div>
|
|
34
|
+
</section>
|
|
35
|
+
|
|
36
|
+
<section class="split">
|
|
37
|
+
<div>
|
|
38
|
+
<SectionIntro eyebrow={model.text('styleEyebrow')} heading={model.text('styleHeading')} />
|
|
39
|
+
<div
|
|
40
|
+
class="prose"
|
|
41
|
+
setInnerHTML={{ __html: model.text('styleIntro'), dangerously: true }}
|
|
42
|
+
/>
|
|
43
|
+
<Figures items={highlightsOf(model, 'stats')} />
|
|
44
|
+
</div>
|
|
45
|
+
{styleImage ? (
|
|
46
|
+
<img
|
|
47
|
+
class="split__image"
|
|
48
|
+
src={styleImage.cropUrl({ width: 1200, height: 800 })}
|
|
49
|
+
alt={styleImage.name}
|
|
50
|
+
/>
|
|
51
|
+
) : null}
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
{range ? (
|
|
55
|
+
<section class="band">
|
|
56
|
+
<SectionIntro eyebrow={model.text('rangeEyebrow')} heading={model.text('rangeHeading')} />
|
|
57
|
+
<ul class="bottlings bottlings--brief">
|
|
58
|
+
{bottlings.map((bottling) => (
|
|
59
|
+
<BottlingCard bottling={bottling} />
|
|
60
|
+
))}
|
|
61
|
+
</ul>
|
|
62
|
+
{model.hasValue('rangeCtaLabel') ? (
|
|
63
|
+
<a class="button" href={range.url}>
|
|
64
|
+
{model.text('rangeCtaLabel')}
|
|
65
|
+
</a>
|
|
66
|
+
) : null}
|
|
67
|
+
</section>
|
|
68
|
+
) : null}
|
|
69
|
+
</Layout>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PageProps } from '@crombolo/crombolo'
|
|
2
|
+
import { BottlingCard } from '../shared/bottling.tsx'
|
|
3
|
+
import { Layout } from '../shared/layout.tsx'
|
|
4
|
+
import { PageHeader } from '../shared/section-intro.tsx'
|
|
5
|
+
|
|
6
|
+
export default function WhiskyList({ model, nav }: PageProps) {
|
|
7
|
+
return (
|
|
8
|
+
<Layout model={model} nav={nav}>
|
|
9
|
+
<PageHeader model={model} />
|
|
10
|
+
<section>
|
|
11
|
+
<ul class="bottlings">
|
|
12
|
+
{nav.children(model).map((bottling) => (
|
|
13
|
+
<BottlingCard bottling={bottling} detailed />
|
|
14
|
+
))}
|
|
15
|
+
</ul>
|
|
16
|
+
</section>
|
|
17
|
+
</Layout>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PublishedContent, PublishedElement } from '@crombolo/crombolo'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The line over a bottling's name: kind, strength, and either an age or a
|
|
5
|
+
* batch. Composed here rather than typed into a field, so an editor who changes
|
|
6
|
+
* the age does not also have to remember to change a string that repeats it.
|
|
7
|
+
*/
|
|
8
|
+
export function specOf(bottling: PublishedContent): string {
|
|
9
|
+
const parts = [bottling.text('kind'), bottling.text('abv')]
|
|
10
|
+
if (bottling.hasValue('age')) parts.push(`${bottling.text('age')} years`)
|
|
11
|
+
else if (bottling.hasValue('batch')) parts.push(`Batch ${bottling.text('batch')}`)
|
|
12
|
+
return parts.filter(Boolean).join(' · ')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function TastingNotes({ notes }: { notes: PublishedElement[] }) {
|
|
16
|
+
if (notes.length === 0) return null
|
|
17
|
+
return (
|
|
18
|
+
<dl class="tasting">
|
|
19
|
+
{notes.map((note) => (
|
|
20
|
+
<div>
|
|
21
|
+
<dt>{note.text('aspect')}</dt>
|
|
22
|
+
<dd>{note.text('note')}</dd>
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</dl>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One bottling, as it appears in a list. `detailed` is what the range page
|
|
31
|
+
* shows and the home page does not: the bottle itself and the tasting notes.
|
|
32
|
+
*/
|
|
33
|
+
export function BottlingCard({
|
|
34
|
+
bottling,
|
|
35
|
+
detailed,
|
|
36
|
+
}: {
|
|
37
|
+
bottling: PublishedContent
|
|
38
|
+
detailed?: boolean
|
|
39
|
+
}) {
|
|
40
|
+
const bottle = bottling.media('bottleImage')[0]
|
|
41
|
+
const notes = (bottling.value('tastingNotes') ?? []) as PublishedElement[]
|
|
42
|
+
return (
|
|
43
|
+
<li class="bottling-card">
|
|
44
|
+
{detailed && bottle ? (
|
|
45
|
+
<a class="bottling-card__image" href={bottling.url}>
|
|
46
|
+
<img src={bottle.cropUrl({ width: 760, height: 1140 })} alt={bottle.name} />
|
|
47
|
+
</a>
|
|
48
|
+
) : null}
|
|
49
|
+
<div class="bottling-card__text">
|
|
50
|
+
<p class="eyebrow">{specOf(bottling)}</p>
|
|
51
|
+
<h3>
|
|
52
|
+
<a href={bottling.url}>{bottling.text('title')}</a>
|
|
53
|
+
</h3>
|
|
54
|
+
{bottling.hasValue('summary') ? <p>{bottling.text('summary')}</p> : null}
|
|
55
|
+
{detailed ? <TastingNotes notes={notes} /> : null}
|
|
56
|
+
</div>
|
|
57
|
+
</li>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PublishedContent, PublishedElement } from '@crombolo/crombolo'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An element picker always yields a list, as Umbraco's converter does, even
|
|
5
|
+
* where the data type allows a single pick.
|
|
6
|
+
*/
|
|
7
|
+
export function highlightsOf(model: PublishedContent, alias: string): PublishedElement[] {
|
|
8
|
+
return (model.value(alias) ?? []) as PublishedElement[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The figures beside the house style: a heading set large, a line under it. */
|
|
12
|
+
export function Figures({ items }: { items: PublishedElement[] }) {
|
|
13
|
+
if (items.length === 0) return null
|
|
14
|
+
return (
|
|
15
|
+
<dl class="figures">
|
|
16
|
+
{items.map((item) => (
|
|
17
|
+
<div>
|
|
18
|
+
<dt>{item.text('heading')}</dt>
|
|
19
|
+
<dd>{item.text('detail')}</dd>
|
|
20
|
+
</div>
|
|
21
|
+
))}
|
|
22
|
+
</dl>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The same elements as a divided list: a short word, then the line beside it. */
|
|
27
|
+
export function HighlightRows({ items }: { items: PublishedElement[] }) {
|
|
28
|
+
if (items.length === 0) return null
|
|
29
|
+
return (
|
|
30
|
+
<ul class="highlight-rows">
|
|
31
|
+
{items.map((item) => (
|
|
32
|
+
<li>
|
|
33
|
+
<span class="highlight-rows__heading">{item.text('heading')}</span>
|
|
34
|
+
<span>{item.text('detail')}</span>
|
|
35
|
+
</li>
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Child, Navigation, PublishedContent } from '@crombolo/crombolo'
|
|
2
|
+
import { SiteFooter } from './site-footer.tsx'
|
|
3
|
+
import { SiteHeader } from './site-header.tsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shell every page shares. Nothing here is a template, because no document
|
|
7
|
+
* type names it — which is the only thing that decides, so `shared/` is a
|
|
8
|
+
* folder chosen for readers rather than a rule.
|
|
9
|
+
*
|
|
10
|
+
* The two faces come from Google Fonts rather than from files in this template:
|
|
11
|
+
* nothing binary ships, and deleting the two <link> tags is the whole cost of
|
|
12
|
+
* taking the third-party request out. `site.css` names a fallback for each, so
|
|
13
|
+
* a blocked request degrades to the system serif and sans rather than to
|
|
14
|
+
* nothing.
|
|
15
|
+
*/
|
|
16
|
+
export function Layout({
|
|
17
|
+
model,
|
|
18
|
+
nav,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
model: PublishedContent
|
|
22
|
+
nav: Navigation
|
|
23
|
+
children?: Child
|
|
24
|
+
}) {
|
|
25
|
+
const home = nav.root()[0]
|
|
26
|
+
const siteName = home?.text('title') || 'Harbourstone'
|
|
27
|
+
const heading = model.text('title') || model.name
|
|
28
|
+
return (
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="utf-8" />
|
|
32
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
33
|
+
<title>{home && home.key !== model.key ? `${heading} — ${siteName}` : siteName}</title>
|
|
34
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
35
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
|
36
|
+
<link
|
|
37
|
+
rel="stylesheet"
|
|
38
|
+
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Outfit:wght@300;400;500&display=swap"
|
|
39
|
+
/>
|
|
40
|
+
<link rel="stylesheet" href="/css/site.css" />
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<SiteHeader model={model} nav={nav} />
|
|
44
|
+
<main>{children}</main>
|
|
45
|
+
<SiteFooter />
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Child, PublishedContent } from '@crombolo/crombolo'
|
|
2
|
+
|
|
3
|
+
/** The small letter-spaced line that sits above every heading on this site. */
|
|
4
|
+
export function Eyebrow({ children, tone }: { children?: Child; tone?: 'light' }) {
|
|
5
|
+
if (!children) return null
|
|
6
|
+
return <p class={tone === 'light' ? 'eyebrow eyebrow--light' : 'eyebrow'}>{children}</p>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The header at the top of a page: eyebrow, title, standfirst. Reads the three
|
|
11
|
+
* properties by the aliases every page type here shares, so a new page type
|
|
12
|
+
* gets the same header by naming its properties the same way.
|
|
13
|
+
*/
|
|
14
|
+
export function PageHeader({ model }: { model: PublishedContent }) {
|
|
15
|
+
return (
|
|
16
|
+
<header class="page-header">
|
|
17
|
+
<Eyebrow>{model.text('eyebrow')}</Eyebrow>
|
|
18
|
+
<h1>{model.text('title')}</h1>
|
|
19
|
+
{model.hasValue('standfirst') ? <p class="standfirst">{model.text('standfirst')}</p> : null}
|
|
20
|
+
</header>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An eyebrow and a heading, for a section inside a page. */
|
|
25
|
+
export function SectionIntro({
|
|
26
|
+
eyebrow,
|
|
27
|
+
heading,
|
|
28
|
+
tone,
|
|
29
|
+
}: {
|
|
30
|
+
eyebrow: string
|
|
31
|
+
heading: string
|
|
32
|
+
tone?: 'light'
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
|
37
|
+
{heading ? <h2>{heading}</h2> : null}
|
|
38
|
+
</>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Wordmark } from './site-header.tsx'
|
|
2
|
+
|
|
3
|
+
export function SiteFooter() {
|
|
4
|
+
return (
|
|
5
|
+
<footer class="site-footer">
|
|
6
|
+
<div class="site-footer__inner">
|
|
7
|
+
<p>
|
|
8
|
+
<Wordmark /> Distillery · Cove of Stenwick, Caithness
|
|
9
|
+
</p>
|
|
10
|
+
<p>A demonstration site. Nothing here is real, and nothing here is for sale.</p>
|
|
11
|
+
</div>
|
|
12
|
+
</footer>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Navigation, PublishedContent } from '@crombolo/crombolo'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The masthead and the primary nav.
|
|
5
|
+
*
|
|
6
|
+
* The nav is the home page followed by its children, labelled with each node's
|
|
7
|
+
* name rather than its title — the About page's title is a sentence, and a nav
|
|
8
|
+
* wants a word. Adding a page in the backoffice adds it here; there is no list
|
|
9
|
+
* of links to keep in step.
|
|
10
|
+
*
|
|
11
|
+
* There is no hamburger because there is no client-side JavaScript on this
|
|
12
|
+
* site: below the breakpoint the links wrap under the wordmark instead, which
|
|
13
|
+
* `site.css` does with flex-wrap alone.
|
|
14
|
+
*/
|
|
15
|
+
export function SiteHeader({ model, nav }: { model: PublishedContent; nav: Navigation }) {
|
|
16
|
+
const home = nav.root()[0]
|
|
17
|
+
if (!home) return null
|
|
18
|
+
const ancestry = model.path.split(',')
|
|
19
|
+
const links = [home, ...nav.children(home)]
|
|
20
|
+
return (
|
|
21
|
+
<header class="masthead">
|
|
22
|
+
<div class="masthead__inner">
|
|
23
|
+
<a class="wordmark" href={home.url}>
|
|
24
|
+
<Wordmark />
|
|
25
|
+
</a>
|
|
26
|
+
<nav aria-label="Primary">
|
|
27
|
+
{links.map((link) => {
|
|
28
|
+
const here =
|
|
29
|
+
link.key === home.key ? model.key === home.key : ancestry.includes(String(link.id))
|
|
30
|
+
return (
|
|
31
|
+
<a
|
|
32
|
+
href={link.url}
|
|
33
|
+
class={here ? 'here' : undefined}
|
|
34
|
+
aria-current={here ? 'page' : undefined}
|
|
35
|
+
>
|
|
36
|
+
{link.key === home.key ? 'Home' : link.name}
|
|
37
|
+
</a>
|
|
38
|
+
)
|
|
39
|
+
})}
|
|
40
|
+
</nav>
|
|
41
|
+
</div>
|
|
42
|
+
</header>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Written out rather than read from the home page's title: a wordmark is a mark
|
|
48
|
+
* with two colours in it, which makes it part of the design and not a value an
|
|
49
|
+
* editor should have to reproduce. The page title stays the editable one.
|
|
50
|
+
*/
|
|
51
|
+
export function Wordmark() {
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
Harbour<span>stone</span>
|
|
55
|
+
</>
|
|
56
|
+
)
|
|
57
|
+
}
|