@cogenta/theme-restaurant 0.3.2
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 +373 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +25 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/render/blocks/accordion.d.ts +4 -0
- package/dist/src/render/blocks/accordion.d.ts.map +1 -0
- package/dist/src/render/blocks/accordion.js +21 -0
- package/dist/src/render/blocks/accordion.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +13 -0
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
- package/dist/src/render/blocks/collection-list.js +109 -0
- package/dist/src/render/blocks/collection-list.js.map +1 -0
- package/dist/src/render/blocks/cta.d.ts +10 -0
- package/dist/src/render/blocks/cta.d.ts.map +1 -0
- package/dist/src/render/blocks/cta.js +13 -0
- package/dist/src/render/blocks/cta.js.map +1 -0
- package/dist/src/render/blocks/embed.d.ts +4 -0
- package/dist/src/render/blocks/embed.d.ts.map +1 -0
- package/dist/src/render/blocks/embed.js +82 -0
- package/dist/src/render/blocks/embed.js.map +1 -0
- package/dist/src/render/blocks/faq.d.ts +4 -0
- package/dist/src/render/blocks/faq.d.ts.map +1 -0
- package/dist/src/render/blocks/faq.js +17 -0
- package/dist/src/render/blocks/faq.js.map +1 -0
- package/dist/src/render/blocks/feature-grid.d.ts +4 -0
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
- package/dist/src/render/blocks/feature-grid.js +24 -0
- package/dist/src/render/blocks/feature-grid.js.map +1 -0
- package/dist/src/render/blocks/gallery.d.ts +11 -0
- package/dist/src/render/blocks/gallery.d.ts.map +1 -0
- package/dist/src/render/blocks/gallery.js +20 -0
- package/dist/src/render/blocks/gallery.js.map +1 -0
- package/dist/src/render/blocks/hero.d.ts +16 -0
- package/dist/src/render/blocks/hero.d.ts.map +1 -0
- package/dist/src/render/blocks/hero.js +32 -0
- package/dist/src/render/blocks/hero.js.map +1 -0
- package/dist/src/render/blocks/logo-strip.d.ts +4 -0
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
- package/dist/src/render/blocks/logo-strip.js +16 -0
- package/dist/src/render/blocks/logo-strip.js.map +1 -0
- package/dist/src/render/blocks/logos.d.ts +4 -0
- package/dist/src/render/blocks/logos.d.ts.map +1 -0
- package/dist/src/render/blocks/logos.js +23 -0
- package/dist/src/render/blocks/logos.js.map +1 -0
- package/dist/src/render/blocks/media-figure.d.ts +9 -0
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
- package/dist/src/render/blocks/media-figure.js +24 -0
- package/dist/src/render/blocks/media-figure.js.map +1 -0
- package/dist/src/render/blocks/pricing-table.d.ts +4 -0
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
- package/dist/src/render/blocks/pricing-table.js +30 -0
- package/dist/src/render/blocks/pricing-table.js.map +1 -0
- package/dist/src/render/blocks/prose.d.ts +13 -0
- package/dist/src/render/blocks/prose.d.ts.map +1 -0
- package/dist/src/render/blocks/prose.js +14 -0
- package/dist/src/render/blocks/prose.js.map +1 -0
- package/dist/src/render/blocks/quote.d.ts +8 -0
- package/dist/src/render/blocks/quote.d.ts.map +1 -0
- package/dist/src/render/blocks/quote.js +21 -0
- package/dist/src/render/blocks/quote.js.map +1 -0
- package/dist/src/render/blocks/stat-counter.d.ts +4 -0
- package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
- package/dist/src/render/blocks/stat-counter.js +15 -0
- package/dist/src/render/blocks/stat-counter.js.map +1 -0
- package/dist/src/render/blocks/stats.d.ts +4 -0
- package/dist/src/render/blocks/stats.d.ts.map +1 -0
- package/dist/src/render/blocks/stats.js +21 -0
- package/dist/src/render/blocks/stats.js.map +1 -0
- package/dist/src/render/blocks/testimonial.d.ts +17 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +26 -0
- package/dist/src/render/blocks/testimonial.js.map +1 -0
- package/dist/src/render/chrome.d.ts +3 -0
- package/dist/src/render/chrome.d.ts.map +1 -0
- package/dist/src/render/chrome.js +108 -0
- package/dist/src/render/chrome.js.map +1 -0
- package/dist/src/render/render-block.d.ts +6 -0
- package/dist/src/render/render-block.d.ts.map +1 -0
- package/dist/src/render/render-block.js +118 -0
- package/dist/src/render/render-block.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +10 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +32 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/theme.config.d.ts +11 -0
- package/dist/theme.config.d.ts.map +1 -0
- package/dist/theme.config.js +41 -0
- package/dist/theme.config.js.map +1 -0
- package/package.json +57 -0
- package/src/index.ts +24 -0
- package/src/render/blocks/accordion.ts +57 -0
- package/src/render/blocks/collection-list.ts +189 -0
- package/src/render/blocks/cta.ts +31 -0
- package/src/render/blocks/embed.ts +105 -0
- package/src/render/blocks/faq.ts +53 -0
- package/src/render/blocks/feature-grid.ts +61 -0
- package/src/render/blocks/gallery.ts +40 -0
- package/src/render/blocks/hero.ts +58 -0
- package/src/render/blocks/logo-strip.ts +35 -0
- package/src/render/blocks/logos.ts +53 -0
- package/src/render/blocks/media-figure.ts +39 -0
- package/src/render/blocks/pricing-table.ts +74 -0
- package/src/render/blocks/prose.ts +19 -0
- package/src/render/blocks/quote.ts +38 -0
- package/src/render/blocks/stat-counter.ts +37 -0
- package/src/render/blocks/stats.ts +48 -0
- package/src/render/blocks/testimonial.ts +42 -0
- package/src/render/chrome.ts +141 -0
- package/src/render/render-block.ts +156 -0
- package/src/render/term-archive.ts +78 -0
- package/src/styles/archive.css +84 -0
- package/src/styles/base.css +923 -0
- package/src/styles/blocks.css +975 -0
- package/src/styles/theme.css +36 -0
- package/src/styles/tokens.css +320 -0
- package/tokens.json +36 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { CollectionListBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
buildCollectionListQuery,
|
|
5
|
+
type ContentEntry,
|
|
6
|
+
entryExcerpt,
|
|
7
|
+
entryImage,
|
|
8
|
+
entryTitle,
|
|
9
|
+
type HeadingTag,
|
|
10
|
+
type HtmlElement,
|
|
11
|
+
h,
|
|
12
|
+
heading,
|
|
13
|
+
nestedHeadingTag,
|
|
14
|
+
type RenderContext,
|
|
15
|
+
renderImageSource,
|
|
16
|
+
} from '@cogenta/theme-kit'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The only block of the seventeen that reads data at render time — contract
|
|
20
|
+
* B marks it `runtime: 'server'` for that reason. The read is *not* done
|
|
21
|
+
* here: `query` builds the request from the block's own fields
|
|
22
|
+
* (`@cogenta/theme-kit`'s `buildCollectionListQuery`, shared across every
|
|
23
|
+
* theme), the caller awaits `ctx.content.list(...)` before rendering starts,
|
|
24
|
+
* and this function stays a pure function of the entries handed to it.
|
|
25
|
+
*/
|
|
26
|
+
export { buildCollectionListQuery as query }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The signature piece of this theme: a `collectionList` on a `menu_item`-shaped
|
|
30
|
+
* collection is rendered as a real, priced menu, not a card grid.
|
|
31
|
+
*
|
|
32
|
+
* Contract B fixes no "menu" block (a menu of dishes is a `collectionList`
|
|
33
|
+
* grouped and formatted by the theme, per `docs/lots/L25-templates-pro.md`
|
|
34
|
+
* "pièges connus" — a new block would need an RFC). Grouping reads the
|
|
35
|
+
* entry's raw `category` field directly: a theme sees a whole entry, not
|
|
36
|
+
* only the fields contract A's system columns declare, and `category` is
|
|
37
|
+
* exactly the field the `restaurant` blueprint's `menu_item` collection
|
|
38
|
+
* names for this. An entry with no `category` (or a collection that never
|
|
39
|
+
* declares one) still renders, in one unlabelled group — grouping is a
|
|
40
|
+
* presentation choice, never a requirement placed back on the schema.
|
|
41
|
+
*
|
|
42
|
+
* `price` is read as a plain number (contract A's `f.number`, not a
|
|
43
|
+
* currency type — commerce's real money lives in contract E, untouched
|
|
44
|
+
* here) and formatted with `Intl.NumberFormat`'s `currency: 'EUR'`: the
|
|
45
|
+
* currency itself is not part of what a theme's `RenderContext` carries, so
|
|
46
|
+
* EUR is this theme's own documented demo default, exactly the way the
|
|
47
|
+
* `store` blueprint's own catalogue prices need no currency conversion
|
|
48
|
+
* either. A site that prices in another currency still gets a sensibly
|
|
49
|
+
* localised number; only the currency symbol would need a real per-site
|
|
50
|
+
* setting this contract does not yet offer.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
function priceLabel(entry: ContentEntry, ctx: RenderContext): string | null {
|
|
54
|
+
const raw = entry.price
|
|
55
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return null
|
|
56
|
+
try {
|
|
57
|
+
return new Intl.NumberFormat(ctx.locale, { style: 'currency', currency: 'EUR' }).format(raw)
|
|
58
|
+
} catch {
|
|
59
|
+
// An `Intl` failure (an exotic `ctx.locale`) is still a real number worth
|
|
60
|
+
// showing, just without locale-aware grouping/decimal marks.
|
|
61
|
+
return raw.toFixed(2)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function categoryOf(entry: ContentEntry): string {
|
|
66
|
+
const raw = entry.category
|
|
67
|
+
return typeof raw === 'string' && raw.trim() !== '' ? raw : ''
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One level below `tag`, clamped at `h6` — the same rule `nestedHeadingTag` applies, extended one step further for the dish name under its category heading. */
|
|
71
|
+
function stepDown(tag: HeadingTag): HeadingTag {
|
|
72
|
+
const level = Math.min(6, Number(tag.slice(1)) + 1)
|
|
73
|
+
return `h${level}` as HeadingTag
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderDish(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
77
|
+
const excerpt = entryExcerpt(entry)
|
|
78
|
+
const price = priceLabel(entry, ctx)
|
|
79
|
+
const photo = entryImage(entry, ctx, { width: 120, height: 120, fit: 'cover' })
|
|
80
|
+
return h(
|
|
81
|
+
'li',
|
|
82
|
+
{ class: 'cg-menu__dish' },
|
|
83
|
+
photo === undefined
|
|
84
|
+
? null
|
|
85
|
+
: h(
|
|
86
|
+
'div',
|
|
87
|
+
{ class: 'cg-menu__dish-photo' },
|
|
88
|
+
renderImageSource(photo, { sizes: '5rem', className: 'cg-menu__dish-image' }),
|
|
89
|
+
),
|
|
90
|
+
h(
|
|
91
|
+
'div',
|
|
92
|
+
{ class: 'cg-menu__dish-body' },
|
|
93
|
+
h(
|
|
94
|
+
'div',
|
|
95
|
+
{ class: 'cg-menu__dish-row' },
|
|
96
|
+
heading(tag, { class: 'cg-menu__dish-name' }, entryTitle(entry, ctx)),
|
|
97
|
+
// Leader and price are one wrapping unit (`cg-menu__dish-tail`): on
|
|
98
|
+
// a narrow screen where the name alone fills the row, the dotted
|
|
99
|
+
// leader must move to the next line together with the price it
|
|
100
|
+
// points to — never left dangling at the end of the name's line
|
|
101
|
+
// with the price it introduces stranded on a line of its own.
|
|
102
|
+
h(
|
|
103
|
+
'span',
|
|
104
|
+
{ class: 'cg-menu__dish-tail' },
|
|
105
|
+
h('span', { class: 'cg-menu__dish-leader', 'aria-hidden': 'true' }),
|
|
106
|
+
price === null ? null : h('span', { class: 'cg-menu__dish-price' }, price),
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
excerpt === undefined ? null : h('p', { class: 'cg-menu__dish-description' }, excerpt),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderGroup(
|
|
115
|
+
category: string,
|
|
116
|
+
entries: readonly ContentEntry[],
|
|
117
|
+
ctx: RenderContext,
|
|
118
|
+
groupTag: HeadingTag,
|
|
119
|
+
): HtmlElement {
|
|
120
|
+
const dishTag = stepDown(groupTag)
|
|
121
|
+
return h(
|
|
122
|
+
'div',
|
|
123
|
+
{ class: 'cg-menu__group' },
|
|
124
|
+
category === '' ? null : heading(groupTag, { class: 'cg-menu__group-title' }, category),
|
|
125
|
+
h(
|
|
126
|
+
'ul',
|
|
127
|
+
{ class: 'cg-menu__dishes' },
|
|
128
|
+
entries.map((entry) => renderDish(entry, ctx, dishTag)),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function groupByCategory(entries: readonly ContentEntry[]): readonly [string, ContentEntry[]][] {
|
|
134
|
+
const order: string[] = []
|
|
135
|
+
const groups = new Map<string, ContentEntry[]>()
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const key = categoryOf(entry)
|
|
138
|
+
const bucket = groups.get(key)
|
|
139
|
+
if (bucket === undefined) {
|
|
140
|
+
groups.set(key, [entry])
|
|
141
|
+
order.push(key)
|
|
142
|
+
} else {
|
|
143
|
+
bucket.push(entry)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return order.map((key) => [key, groups.get(key) as ContentEntry[]])
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function renderCollectionList(
|
|
150
|
+
block: CollectionListBlock,
|
|
151
|
+
ctx: RenderContext,
|
|
152
|
+
entries: readonly ContentEntry[],
|
|
153
|
+
): HtmlElement {
|
|
154
|
+
const hasTitle = block.title !== undefined
|
|
155
|
+
const groupTag = nestedHeadingTag('collectionList', hasTitle)
|
|
156
|
+
const groups = groupByCategory(entries)
|
|
157
|
+
const body =
|
|
158
|
+
entries.length === 0
|
|
159
|
+
? h('p', { class: 'cg-menu__empty' }, ctx.t('collection.empty'))
|
|
160
|
+
: h(
|
|
161
|
+
'div',
|
|
162
|
+
{ class: 'cg-menu__groups' },
|
|
163
|
+
groups.map(([category, items]) => renderGroup(category, items, ctx, groupTag)),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return h(
|
|
167
|
+
'section',
|
|
168
|
+
{ class: 'cg-menu', 'data-block': 'collectionList', 'data-layout': block.layout },
|
|
169
|
+
hasTitle
|
|
170
|
+
? heading(
|
|
171
|
+
blockHeadingTag('collectionList') ?? 'h2',
|
|
172
|
+
{ class: 'cg-menu__title', 'data-field': 'title' },
|
|
173
|
+
block.title ?? '',
|
|
174
|
+
)
|
|
175
|
+
: null,
|
|
176
|
+
block.layout === 'carousel'
|
|
177
|
+
? h(
|
|
178
|
+
'div',
|
|
179
|
+
{
|
|
180
|
+
class: 'cg-menu__viewport',
|
|
181
|
+
role: 'region',
|
|
182
|
+
'aria-label': block.title ?? ctx.t('collection.carousel'),
|
|
183
|
+
tabindex: '0',
|
|
184
|
+
},
|
|
185
|
+
body,
|
|
186
|
+
)
|
|
187
|
+
: body,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CtaBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
actionList,
|
|
4
|
+
blockHeadingTag,
|
|
5
|
+
type HtmlElement,
|
|
6
|
+
h,
|
|
7
|
+
heading,
|
|
8
|
+
type RenderContext,
|
|
9
|
+
} from '@cogenta/theme-kit'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* "Book now / Call us" — a centred, dark, full-bleed close, mirroring the
|
|
13
|
+
* hero's own centred treatment so the page opens and closes on the same
|
|
14
|
+
* note. `--cg-scrim` gives the panel its depth; nothing here is a literal
|
|
15
|
+
* colour (R3/isolation test).
|
|
16
|
+
*/
|
|
17
|
+
export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
|
|
18
|
+
return h(
|
|
19
|
+
'section',
|
|
20
|
+
{ class: 'cg-close', 'data-block': 'cta' },
|
|
21
|
+
heading(
|
|
22
|
+
blockHeadingTag('cta') ?? 'h2',
|
|
23
|
+
{ class: 'cg-close__title', 'data-field': 'title' },
|
|
24
|
+
block.title,
|
|
25
|
+
),
|
|
26
|
+
block.text === undefined
|
|
27
|
+
? null
|
|
28
|
+
: h('p', { class: 'cg-close__text', 'data-field': 'text' }, block.text),
|
|
29
|
+
actionList(ctx, block.actions, block.title),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { EmbedBlock } from '@cogenta/blocks'
|
|
2
|
+
import { aspectRatio, type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Nothing here contacts a third party before the visitor has consented —
|
|
6
|
+
* this theme's own blueprint uses `provider: 'other'` for a map embed
|
|
7
|
+
* (`consentRequired: true`), which this renderer never has a trusted frame
|
|
8
|
+
* source for anyway, so it always renders the honest placeholder card below.
|
|
9
|
+
*
|
|
10
|
+
* When `consentRequired` is true (or the provider/URL cannot be turned into
|
|
11
|
+
* a trusted frame source) the block renders a self-contained card and an
|
|
12
|
+
* outbound link — no `<iframe>`, no `<script>`, no preconnect. An embed
|
|
13
|
+
* that "only" loads a thumbnail has already handed the visitor's IP address
|
|
14
|
+
* and a cookie to the provider, which is the exact transfer consent was
|
|
15
|
+
* supposed to gate. The theme ships no consent *button*, because it ships
|
|
16
|
+
* no JavaScript: granting consent is a site-wide decision that belongs to
|
|
17
|
+
* the consent layer, not to a block.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** `null` means "this provider has no embeddable frame we trust". */
|
|
21
|
+
function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
|
|
22
|
+
const url = URL.parse(rawUrl)
|
|
23
|
+
if (url === null) return null
|
|
24
|
+
const segments = url.pathname.split('/').filter((segment) => segment !== '')
|
|
25
|
+
|
|
26
|
+
switch (provider) {
|
|
27
|
+
case 'youtube': {
|
|
28
|
+
const id = url.hostname.endsWith('youtu.be')
|
|
29
|
+
? segments[0]
|
|
30
|
+
: (url.searchParams.get('v') ?? segments[1])
|
|
31
|
+
return id === undefined || id === null ? null : `https://www.youtube-nocookie.com/embed/${id}`
|
|
32
|
+
}
|
|
33
|
+
case 'vimeo': {
|
|
34
|
+
const id = segments.find((segment) => /^\d+$/.test(segment))
|
|
35
|
+
return id === undefined ? null : `https://player.vimeo.com/video/${id}`
|
|
36
|
+
}
|
|
37
|
+
case 'dailymotion': {
|
|
38
|
+
const id = url.hostname.endsWith('dai.ly') ? segments[0] : segments[segments.length - 1]
|
|
39
|
+
return id === undefined ? null : `https://geo.dailymotion.com/player.html?video=${id}`
|
|
40
|
+
}
|
|
41
|
+
case 'spotify': {
|
|
42
|
+
const [kind, id] = segments
|
|
43
|
+
return kind === undefined || id === undefined
|
|
44
|
+
? null
|
|
45
|
+
: `https://open.spotify.com/embed/${kind}/${id}`
|
|
46
|
+
}
|
|
47
|
+
case 'soundcloud':
|
|
48
|
+
return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
|
|
49
|
+
default:
|
|
50
|
+
// bluesky, mastodon, other (a map link, in this theme's own
|
|
51
|
+
// blueprint): the link card is the rendering.
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
|
|
57
|
+
return h(
|
|
58
|
+
'div',
|
|
59
|
+
{ class: 'cg-embed__placeholder' },
|
|
60
|
+
h('p', { class: 'cg-embed__provider', 'aria-hidden': 'true' }, block.provider),
|
|
61
|
+
h('p', { class: 'cg-embed__notice' }, reason),
|
|
62
|
+
h(
|
|
63
|
+
'a',
|
|
64
|
+
{
|
|
65
|
+
class: 'cg-embed__link',
|
|
66
|
+
href: ctx.link(block.url),
|
|
67
|
+
rel: 'noopener noreferrer nofollow',
|
|
68
|
+
},
|
|
69
|
+
ctx.t('embed.open', { provider: block.provider }),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
|
|
75
|
+
const source = block.consentRequired ? null : frameSource(block.provider, block.url)
|
|
76
|
+
const ratio = aspectRatio(block.ratio) ?? '16 / 9'
|
|
77
|
+
|
|
78
|
+
return h(
|
|
79
|
+
'div',
|
|
80
|
+
{
|
|
81
|
+
class: 'cg-embed',
|
|
82
|
+
'data-block': 'embed',
|
|
83
|
+
'data-provider': block.provider,
|
|
84
|
+
'data-consent': block.consentRequired ? 'required' : 'not-required',
|
|
85
|
+
style: `--cg-ratio:${ratio}`,
|
|
86
|
+
},
|
|
87
|
+
source === null
|
|
88
|
+
? consentCard(
|
|
89
|
+
block,
|
|
90
|
+
ctx,
|
|
91
|
+
block.consentRequired
|
|
92
|
+
? ctx.t('embed.consentRequired', { provider: block.provider })
|
|
93
|
+
: ctx.t('embed.unsupported', { provider: block.provider }),
|
|
94
|
+
)
|
|
95
|
+
: h('iframe', {
|
|
96
|
+
class: 'cg-embed__frame',
|
|
97
|
+
src: source,
|
|
98
|
+
title: ctx.t('embed.title', { provider: block.provider }),
|
|
99
|
+
loading: 'lazy',
|
|
100
|
+
referrerpolicy: 'strict-origin-when-cross-origin',
|
|
101
|
+
allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
|
|
102
|
+
allowfullscreen: true,
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FaqBlock, FaqItem } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
type RenderContext,
|
|
8
|
+
renderRichText,
|
|
9
|
+
} from '@cogenta/theme-kit'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `<details>`/`<summary>` — zero bytes of JavaScript. Kept visually distinct
|
|
13
|
+
* from `accordion` (this theme's own "Hours & location"): each question
|
|
14
|
+
* carries a small serif numeral rather than the plus/minus mark, the
|
|
15
|
+
* "questions before you book" register of a restaurant's own FAQ.
|
|
16
|
+
*/
|
|
17
|
+
function renderItem(item: FaqItem, ctx: RenderContext, index: number): HtmlElement {
|
|
18
|
+
const number = String(index + 1).padStart(2, '0')
|
|
19
|
+
return h(
|
|
20
|
+
'li',
|
|
21
|
+
{ class: 'cg-faq__item' },
|
|
22
|
+
h(
|
|
23
|
+
'details',
|
|
24
|
+
{ class: 'cg-faq__details' },
|
|
25
|
+
h(
|
|
26
|
+
'summary',
|
|
27
|
+
{ class: 'cg-faq__question' },
|
|
28
|
+
h('span', { class: 'cg-faq__index', 'aria-hidden': 'true' }, number),
|
|
29
|
+
h('span', { class: 'cg-faq__question-text' }, item.question),
|
|
30
|
+
),
|
|
31
|
+
h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
|
|
37
|
+
return h(
|
|
38
|
+
'section',
|
|
39
|
+
{ class: 'cg-faq', 'data-block': 'faq' },
|
|
40
|
+
block.title === undefined
|
|
41
|
+
? null
|
|
42
|
+
: heading(
|
|
43
|
+
blockHeadingTag('faq') ?? 'h2',
|
|
44
|
+
{ class: 'cg-faq__title', 'data-field': 'title' },
|
|
45
|
+
block.title,
|
|
46
|
+
),
|
|
47
|
+
h(
|
|
48
|
+
'ul',
|
|
49
|
+
{ class: 'cg-faq__items' },
|
|
50
|
+
block.items.map((item, index) => renderItem(item, ctx, index)),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { FeatureGridBlock, FeatureItem } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HeadingTag,
|
|
5
|
+
type HtmlElement,
|
|
6
|
+
h,
|
|
7
|
+
heading,
|
|
8
|
+
href,
|
|
9
|
+
nestedHeadingTag,
|
|
10
|
+
type RenderContext,
|
|
11
|
+
renderIcon,
|
|
12
|
+
} from '@cogenta/theme-kit'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A row of three centred columns, each topped by a thin circular icon mark —
|
|
16
|
+
* the "how we cook" / "what makes this kitchen" convention, never a boxed
|
|
17
|
+
* feature card.
|
|
18
|
+
*/
|
|
19
|
+
function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
20
|
+
const title =
|
|
21
|
+
item.link === undefined
|
|
22
|
+
? heading(tag, { class: 'cg-craft__title' }, item.title)
|
|
23
|
+
: heading(
|
|
24
|
+
tag,
|
|
25
|
+
{ class: 'cg-craft__title' },
|
|
26
|
+
h('a', { class: 'cg-craft__link', href: href(ctx, item.link) }, item.title),
|
|
27
|
+
)
|
|
28
|
+
const icon =
|
|
29
|
+
item.icon === undefined
|
|
30
|
+
? null
|
|
31
|
+
: (renderIcon(item.icon, { className: 'cg-craft__glyph' }) ??
|
|
32
|
+
h('span', { class: 'cg-craft__mark-fallback', 'data-icon': item.icon }))
|
|
33
|
+
return h(
|
|
34
|
+
'li',
|
|
35
|
+
{ class: 'cg-craft__item' },
|
|
36
|
+
icon === null ? null : h('span', { class: 'cg-craft__mark', 'aria-hidden': 'true' }, icon),
|
|
37
|
+
title,
|
|
38
|
+
item.text === undefined ? null : h('p', { class: 'cg-craft__text' }, item.text),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
|
|
43
|
+
const hasTitle = block.title !== undefined
|
|
44
|
+
const itemTag = nestedHeadingTag('featureGrid', hasTitle)
|
|
45
|
+
return h(
|
|
46
|
+
'section',
|
|
47
|
+
{ class: 'cg-craft', 'data-block': 'featureGrid' },
|
|
48
|
+
hasTitle
|
|
49
|
+
? heading(
|
|
50
|
+
blockHeadingTag('featureGrid') ?? 'h2',
|
|
51
|
+
{ class: 'cg-craft__title-heading', 'data-field': 'title' },
|
|
52
|
+
block.title ?? '',
|
|
53
|
+
)
|
|
54
|
+
: null,
|
|
55
|
+
h(
|
|
56
|
+
'ul',
|
|
57
|
+
{ class: 'cg-craft__items' },
|
|
58
|
+
block.items.map((item) => renderItem(item, ctx, itemTag)),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { GalleryBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ships **no JavaScript**. `layout: 'masonry'` (the dining-room gallery this
|
|
6
|
+
* theme's own blueprint seeds) is a CSS multi-column flow — `column-count`
|
|
7
|
+
* with `break-inside: avoid` on each tile — which needs no measurement pass
|
|
8
|
+
* a script would otherwise have to do; `grid`/`carousel` keep the same
|
|
9
|
+
* zero-JS scroll-snapping list every other layout in this theme uses.
|
|
10
|
+
*/
|
|
11
|
+
export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
|
|
12
|
+
const items = h(
|
|
13
|
+
'ul',
|
|
14
|
+
{ class: 'cg-gallery__items' },
|
|
15
|
+
block.items.map((item) =>
|
|
16
|
+
h(
|
|
17
|
+
'li',
|
|
18
|
+
{ class: 'cg-gallery__item' },
|
|
19
|
+
image(ctx, item.media, { sizes: '(min-width: 48rem) 22rem, 60vw' }),
|
|
20
|
+
),
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return h(
|
|
25
|
+
'section',
|
|
26
|
+
{ class: 'cg-gallery', 'data-block': 'gallery', 'data-layout': block.layout },
|
|
27
|
+
block.layout === 'carousel'
|
|
28
|
+
? h(
|
|
29
|
+
'div',
|
|
30
|
+
{
|
|
31
|
+
class: 'cg-gallery__viewport',
|
|
32
|
+
role: 'region',
|
|
33
|
+
'aria-label': ctx.t('gallery.carousel'),
|
|
34
|
+
tabindex: '0',
|
|
35
|
+
},
|
|
36
|
+
items,
|
|
37
|
+
)
|
|
38
|
+
: items,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { HeroBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
actionList,
|
|
4
|
+
blockHeadingTag,
|
|
5
|
+
type HtmlElement,
|
|
6
|
+
h,
|
|
7
|
+
heading,
|
|
8
|
+
image,
|
|
9
|
+
type RenderContext,
|
|
10
|
+
} from '@cogenta/theme-kit'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The full-bleed dining-room hero: the media (when present) fills the whole
|
|
14
|
+
* band, a gradient scrim sits between it and the copy so the centred text
|
|
15
|
+
* stays legible over a photo of any tone, and the eyebrow/title/subtitle are
|
|
16
|
+
* centred — the "elegant restaurant homepage" convention (Divi/Astra
|
|
17
|
+
* "Restaurant") rather than the left-aligned marketing hero the other
|
|
18
|
+
* built-in themes use.
|
|
19
|
+
*
|
|
20
|
+
* The scrim is a CSS gradient composed from `--cg-scrim` (itself derived
|
|
21
|
+
* from the skin's own ink, see `tokens.css`), never a literal colour: a
|
|
22
|
+
* skin with a lighter ink still gets a scrim tuned to it.
|
|
23
|
+
*/
|
|
24
|
+
export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
|
|
25
|
+
const tag = blockHeadingTag('hero') ?? 'h1'
|
|
26
|
+
return h(
|
|
27
|
+
'section',
|
|
28
|
+
{
|
|
29
|
+
class: 'cg-hero',
|
|
30
|
+
'data-block': 'hero',
|
|
31
|
+
'data-has-media': block.media === undefined ? 'false' : 'true',
|
|
32
|
+
},
|
|
33
|
+
block.media === undefined
|
|
34
|
+
? null
|
|
35
|
+
: h(
|
|
36
|
+
'div',
|
|
37
|
+
{ class: 'cg-hero__media' },
|
|
38
|
+
image(ctx, block.media, {
|
|
39
|
+
className: 'cg-hero__image',
|
|
40
|
+
loading: 'eager',
|
|
41
|
+
sizes: '100vw',
|
|
42
|
+
}),
|
|
43
|
+
h('div', { class: 'cg-hero__scrim', 'aria-hidden': 'true' }),
|
|
44
|
+
),
|
|
45
|
+
h(
|
|
46
|
+
'div',
|
|
47
|
+
{ class: 'cg-hero__body' },
|
|
48
|
+
block.eyebrow === undefined
|
|
49
|
+
? null
|
|
50
|
+
: h('p', { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
|
|
51
|
+
heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
|
|
52
|
+
block.subtitle === undefined
|
|
53
|
+
? null
|
|
54
|
+
: h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
|
|
55
|
+
actionList(ctx, block.actions, ctx.t('hero.actions')),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `blocks@2.0` (RFC 0001). The lighter-weight "as seen in" row next to
|
|
6
|
+
* `logos`: no per-logo name or link, so no accessible-name field to
|
|
7
|
+
* require — each image's own alt text (set once, in the media library)
|
|
8
|
+
* names it, exactly as `logos` falls back to when a link is absent.
|
|
9
|
+
*/
|
|
10
|
+
function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
|
|
11
|
+
return h(
|
|
12
|
+
'li',
|
|
13
|
+
{ class: 'cg-press-strip__item' },
|
|
14
|
+
image(ctx, item.media, { className: 'cg-press-strip__image', variant: { fit: 'contain' } }),
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
|
|
19
|
+
return h(
|
|
20
|
+
'figure',
|
|
21
|
+
{ class: 'cg-press-strip', 'data-block': 'logoStrip' },
|
|
22
|
+
h(
|
|
23
|
+
'ul',
|
|
24
|
+
{ class: 'cg-press-strip__items' },
|
|
25
|
+
block.logos.map((item) => renderItem(item, ctx)),
|
|
26
|
+
),
|
|
27
|
+
block.caption === undefined
|
|
28
|
+
? null
|
|
29
|
+
: h(
|
|
30
|
+
'figcaption',
|
|
31
|
+
{ class: 'cg-press-strip__caption', 'data-field': 'caption' },
|
|
32
|
+
block.caption,
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { LogoItem, LogosBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
image,
|
|
8
|
+
type RenderContext,
|
|
9
|
+
} from '@cogenta/theme-kit'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* "Featured in" / press mentions, held back to a quiet grayscale until
|
|
13
|
+
* hovered — contract B says the organisation's name **is** the accessible
|
|
14
|
+
* name of the link, so the logo image carries it as `alt` text when the
|
|
15
|
+
* media entity has none, and no visually hidden duplicate is added.
|
|
16
|
+
*/
|
|
17
|
+
function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
|
|
18
|
+
const logo = image(ctx, item.media, {
|
|
19
|
+
className: 'cg-press__logo',
|
|
20
|
+
altFrom: item.name,
|
|
21
|
+
variant: { fit: 'contain' },
|
|
22
|
+
})
|
|
23
|
+
return h(
|
|
24
|
+
'li',
|
|
25
|
+
{ class: 'cg-press__item' },
|
|
26
|
+
item.url === undefined
|
|
27
|
+
? logo
|
|
28
|
+
: h(
|
|
29
|
+
'a',
|
|
30
|
+
{ class: 'cg-press__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
|
|
31
|
+
logo,
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
|
|
37
|
+
return h(
|
|
38
|
+
'section',
|
|
39
|
+
{ class: 'cg-press', 'data-block': 'logos' },
|
|
40
|
+
block.title === undefined
|
|
41
|
+
? null
|
|
42
|
+
: heading(
|
|
43
|
+
blockHeadingTag('logos') ?? 'h2',
|
|
44
|
+
{ class: 'cg-press__title', 'data-field': 'title' },
|
|
45
|
+
block.title,
|
|
46
|
+
),
|
|
47
|
+
h(
|
|
48
|
+
'ul',
|
|
49
|
+
{ class: 'cg-press__items' },
|
|
50
|
+
block.items.map((item) => renderItem(item, ctx)),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MediaFigureBlock } from '@cogenta/blocks'
|
|
2
|
+
import { aspectRatio, type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A thin hairline frame around the image, a small italic caption beneath —
|
|
6
|
+
* the restrained, "printed menu insert" treatment consistent with this
|
|
7
|
+
* theme's gallery and embed placeholder.
|
|
8
|
+
*/
|
|
9
|
+
export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext): HtmlElement {
|
|
10
|
+
const ratio = aspectRatio(block.ratio)
|
|
11
|
+
const hasCaption = block.caption !== undefined || block.credit !== undefined
|
|
12
|
+
return h(
|
|
13
|
+
'figure',
|
|
14
|
+
{
|
|
15
|
+
class: 'cg-plate',
|
|
16
|
+
'data-block': 'mediaFigure',
|
|
17
|
+
'data-align': block.align ?? 'center',
|
|
18
|
+
style: ratio === undefined ? undefined : `--cg-ratio:${ratio}`,
|
|
19
|
+
},
|
|
20
|
+
h(
|
|
21
|
+
'div',
|
|
22
|
+
{ class: 'cg-plate__frame' },
|
|
23
|
+
image(ctx, block.media, {
|
|
24
|
+
className: 'cg-plate__media',
|
|
25
|
+
sizes: '(min-width: 48rem) 42rem, 100vw',
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
hasCaption
|
|
29
|
+
? h(
|
|
30
|
+
'figcaption',
|
|
31
|
+
{ class: 'cg-plate__caption' },
|
|
32
|
+
block.caption,
|
|
33
|
+
block.credit === undefined
|
|
34
|
+
? null
|
|
35
|
+
: h('span', { class: 'cg-plate__credit', 'data-field': 'credit' }, block.credit),
|
|
36
|
+
)
|
|
37
|
+
: null,
|
|
38
|
+
)
|
|
39
|
+
}
|