@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.
Files changed (117) hide show
  1. package/LICENSE +373 -0
  2. package/dist/src/index.d.ts +22 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/index.js +25 -0
  5. package/dist/src/index.js.map +1 -0
  6. package/dist/src/render/blocks/accordion.d.ts +4 -0
  7. package/dist/src/render/blocks/accordion.d.ts.map +1 -0
  8. package/dist/src/render/blocks/accordion.js +21 -0
  9. package/dist/src/render/blocks/accordion.js.map +1 -0
  10. package/dist/src/render/blocks/collection-list.d.ts +13 -0
  11. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  12. package/dist/src/render/blocks/collection-list.js +109 -0
  13. package/dist/src/render/blocks/collection-list.js.map +1 -0
  14. package/dist/src/render/blocks/cta.d.ts +10 -0
  15. package/dist/src/render/blocks/cta.d.ts.map +1 -0
  16. package/dist/src/render/blocks/cta.js +13 -0
  17. package/dist/src/render/blocks/cta.js.map +1 -0
  18. package/dist/src/render/blocks/embed.d.ts +4 -0
  19. package/dist/src/render/blocks/embed.d.ts.map +1 -0
  20. package/dist/src/render/blocks/embed.js +82 -0
  21. package/dist/src/render/blocks/embed.js.map +1 -0
  22. package/dist/src/render/blocks/faq.d.ts +4 -0
  23. package/dist/src/render/blocks/faq.d.ts.map +1 -0
  24. package/dist/src/render/blocks/faq.js +17 -0
  25. package/dist/src/render/blocks/faq.js.map +1 -0
  26. package/dist/src/render/blocks/feature-grid.d.ts +4 -0
  27. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
  28. package/dist/src/render/blocks/feature-grid.js +24 -0
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -0
  30. package/dist/src/render/blocks/gallery.d.ts +11 -0
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  32. package/dist/src/render/blocks/gallery.js +20 -0
  33. package/dist/src/render/blocks/gallery.js.map +1 -0
  34. package/dist/src/render/blocks/hero.d.ts +16 -0
  35. package/dist/src/render/blocks/hero.d.ts.map +1 -0
  36. package/dist/src/render/blocks/hero.js +32 -0
  37. package/dist/src/render/blocks/hero.js.map +1 -0
  38. package/dist/src/render/blocks/logo-strip.d.ts +4 -0
  39. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
  40. package/dist/src/render/blocks/logo-strip.js +16 -0
  41. package/dist/src/render/blocks/logo-strip.js.map +1 -0
  42. package/dist/src/render/blocks/logos.d.ts +4 -0
  43. package/dist/src/render/blocks/logos.d.ts.map +1 -0
  44. package/dist/src/render/blocks/logos.js +23 -0
  45. package/dist/src/render/blocks/logos.js.map +1 -0
  46. package/dist/src/render/blocks/media-figure.d.ts +9 -0
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  48. package/dist/src/render/blocks/media-figure.js +24 -0
  49. package/dist/src/render/blocks/media-figure.js.map +1 -0
  50. package/dist/src/render/blocks/pricing-table.d.ts +4 -0
  51. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
  52. package/dist/src/render/blocks/pricing-table.js +30 -0
  53. package/dist/src/render/blocks/pricing-table.js.map +1 -0
  54. package/dist/src/render/blocks/prose.d.ts +13 -0
  55. package/dist/src/render/blocks/prose.d.ts.map +1 -0
  56. package/dist/src/render/blocks/prose.js +14 -0
  57. package/dist/src/render/blocks/prose.js.map +1 -0
  58. package/dist/src/render/blocks/quote.d.ts +8 -0
  59. package/dist/src/render/blocks/quote.d.ts.map +1 -0
  60. package/dist/src/render/blocks/quote.js +21 -0
  61. package/dist/src/render/blocks/quote.js.map +1 -0
  62. package/dist/src/render/blocks/stat-counter.d.ts +4 -0
  63. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
  64. package/dist/src/render/blocks/stat-counter.js +15 -0
  65. package/dist/src/render/blocks/stat-counter.js.map +1 -0
  66. package/dist/src/render/blocks/stats.d.ts +4 -0
  67. package/dist/src/render/blocks/stats.d.ts.map +1 -0
  68. package/dist/src/render/blocks/stats.js +21 -0
  69. package/dist/src/render/blocks/stats.js.map +1 -0
  70. package/dist/src/render/blocks/testimonial.d.ts +17 -0
  71. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  72. package/dist/src/render/blocks/testimonial.js +26 -0
  73. package/dist/src/render/blocks/testimonial.js.map +1 -0
  74. package/dist/src/render/chrome.d.ts +3 -0
  75. package/dist/src/render/chrome.d.ts.map +1 -0
  76. package/dist/src/render/chrome.js +108 -0
  77. package/dist/src/render/chrome.js.map +1 -0
  78. package/dist/src/render/render-block.d.ts +6 -0
  79. package/dist/src/render/render-block.d.ts.map +1 -0
  80. package/dist/src/render/render-block.js +118 -0
  81. package/dist/src/render/render-block.js.map +1 -0
  82. package/dist/src/render/term-archive.d.ts +10 -0
  83. package/dist/src/render/term-archive.d.ts.map +1 -0
  84. package/dist/src/render/term-archive.js +32 -0
  85. package/dist/src/render/term-archive.js.map +1 -0
  86. package/dist/theme.config.d.ts +11 -0
  87. package/dist/theme.config.d.ts.map +1 -0
  88. package/dist/theme.config.js +41 -0
  89. package/dist/theme.config.js.map +1 -0
  90. package/package.json +57 -0
  91. package/src/index.ts +24 -0
  92. package/src/render/blocks/accordion.ts +57 -0
  93. package/src/render/blocks/collection-list.ts +189 -0
  94. package/src/render/blocks/cta.ts +31 -0
  95. package/src/render/blocks/embed.ts +105 -0
  96. package/src/render/blocks/faq.ts +53 -0
  97. package/src/render/blocks/feature-grid.ts +61 -0
  98. package/src/render/blocks/gallery.ts +40 -0
  99. package/src/render/blocks/hero.ts +58 -0
  100. package/src/render/blocks/logo-strip.ts +35 -0
  101. package/src/render/blocks/logos.ts +53 -0
  102. package/src/render/blocks/media-figure.ts +39 -0
  103. package/src/render/blocks/pricing-table.ts +74 -0
  104. package/src/render/blocks/prose.ts +19 -0
  105. package/src/render/blocks/quote.ts +38 -0
  106. package/src/render/blocks/stat-counter.ts +37 -0
  107. package/src/render/blocks/stats.ts +48 -0
  108. package/src/render/blocks/testimonial.ts +42 -0
  109. package/src/render/chrome.ts +141 -0
  110. package/src/render/render-block.ts +156 -0
  111. package/src/render/term-archive.ts +78 -0
  112. package/src/styles/archive.css +84 -0
  113. package/src/styles/base.css +923 -0
  114. package/src/styles/blocks.css +975 -0
  115. package/src/styles/theme.css +36 -0
  116. package/src/styles/tokens.css +320 -0
  117. 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
+ }