@cogenta/theme-entreprise 1.1.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 +16 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +16 -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 +24 -0
- package/dist/src/render/blocks/accordion.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +14 -0
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
- package/dist/src/render/blocks/collection-list.js +55 -0
- package/dist/src/render/blocks/collection-list.js.map +1 -0
- package/dist/src/render/blocks/cta.d.ts +12 -0
- package/dist/src/render/blocks/cta.d.ts.map +1 -0
- package/dist/src/render/blocks/cta.js +19 -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 +92 -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 +25 -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 +33 -0
- package/dist/src/render/blocks/feature-grid.js.map +1 -0
- package/dist/src/render/blocks/gallery.d.ts +15 -0
- package/dist/src/render/blocks/gallery.d.ts.map +1 -0
- package/dist/src/render/blocks/gallery.js +28 -0
- package/dist/src/render/blocks/gallery.js.map +1 -0
- package/dist/src/render/blocks/hero.d.ts +15 -0
- package/dist/src/render/blocks/hero.d.ts.map +1 -0
- package/dist/src/render/blocks/hero.js +31 -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 +21 -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 +30 -0
- package/dist/src/render/blocks/logos.js.map +1 -0
- package/dist/src/render/blocks/media-figure.d.ts +13 -0
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
- package/dist/src/render/blocks/media-figure.js +28 -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 +33 -0
- package/dist/src/render/blocks/pricing-table.js.map +1 -0
- package/dist/src/render/blocks/prose.d.ts +9 -0
- package/dist/src/render/blocks/prose.d.ts.map +1 -0
- package/dist/src/render/blocks/prose.js +10 -0
- package/dist/src/render/blocks/prose.js.map +1 -0
- package/dist/src/render/blocks/quote.d.ts +18 -0
- package/dist/src/render/blocks/quote.d.ts.map +1 -0
- package/dist/src/render/blocks/quote.js +31 -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 +23 -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 +23 -0
- package/dist/src/render/blocks/stats.js.map +1 -0
- package/dist/src/render/blocks/testimonial.d.ts +16 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +25 -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 +101 -0
- package/dist/src/render/chrome.js.map +1 -0
- package/dist/src/render/render-block.d.ts +24 -0
- package/dist/src/render/render-block.d.ts.map +1 -0
- package/dist/src/render/render-block.js +108 -0
- package/dist/src/render/render-block.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +12 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +34 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/theme.config.d.ts +16 -0
- package/dist/theme.config.d.ts.map +1 -0
- package/dist/theme.config.js +46 -0
- package/dist/theme.config.js.map +1 -0
- package/package.json +57 -0
- package/src/index.ts +15 -0
- package/src/render/blocks/accordion.ts +60 -0
- package/src/render/blocks/collection-list.ts +122 -0
- package/src/render/blocks/cta.ts +48 -0
- package/src/render/blocks/embed.ts +115 -0
- package/src/render/blocks/faq.ts +61 -0
- package/src/render/blocks/feature-grid.ts +73 -0
- package/src/render/blocks/gallery.ts +48 -0
- package/src/render/blocks/hero.ts +60 -0
- package/src/render/blocks/logo-strip.ts +36 -0
- package/src/render/blocks/logos.ts +60 -0
- package/src/render/blocks/media-figure.ts +43 -0
- package/src/render/blocks/pricing-table.ts +77 -0
- package/src/render/blocks/prose.ts +11 -0
- package/src/render/blocks/quote.ts +48 -0
- package/src/render/blocks/stat-counter.ts +45 -0
- package/src/render/blocks/stats.ts +50 -0
- package/src/render/blocks/testimonial.ts +41 -0
- package/src/render/chrome.ts +128 -0
- package/src/render/render-block.ts +144 -0
- package/src/render/term-archive.ts +80 -0
- package/src/styles/archive.css +68 -0
- package/src/styles/base.css +895 -0
- package/src/styles/blocks.css +1433 -0
- package/src/styles/theme.css +36 -0
- package/src/styles/tokens.css +326 -0
- package/tokens.json +36 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AccordionBlock, AccordionItem } 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
|
+
* `blocks@2.0` (RFC 0001). Reuses `faq`'s zero-JS `<details>`/`<summary>`
|
|
13
|
+
* mechanics — expanding, keyboard operation and the expanded state
|
|
14
|
+
* announced to assistive technology all come from the browser — but with a
|
|
15
|
+
* markup vocabulary and class names of its own (`cg-panels`, not
|
|
16
|
+
* `cg-faq`) so the two blocks can diverge visually later without one
|
|
17
|
+
* depending on the other: "accordion" and "frequently asked question" are
|
|
18
|
+
* different editorial intents even where the underlying question/answer
|
|
19
|
+
* shape coincides.
|
|
20
|
+
*
|
|
21
|
+
* Where `faq` numbers its rows, this one reads as a stack of bordered
|
|
22
|
+
* panels with a rotating chevron mark, closer to a features/specification
|
|
23
|
+
* accordion than a support page.
|
|
24
|
+
*/
|
|
25
|
+
function renderItem(item: AccordionItem, ctx: RenderContext): HtmlElement {
|
|
26
|
+
return h(
|
|
27
|
+
'li',
|
|
28
|
+
{ class: 'cg-panels__item' },
|
|
29
|
+
h(
|
|
30
|
+
'details',
|
|
31
|
+
{ class: 'cg-panels__details' },
|
|
32
|
+
h(
|
|
33
|
+
'summary',
|
|
34
|
+
{ class: 'cg-panels__question' },
|
|
35
|
+
h('span', { class: 'cg-panels__question-text' }, item.question),
|
|
36
|
+
h('span', { class: 'cg-panels__chevron', 'aria-hidden': 'true' }),
|
|
37
|
+
),
|
|
38
|
+
h('div', { class: 'cg-panels__answer' }, renderRichText(ctx, item.answer)),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderAccordion(block: AccordionBlock, ctx: RenderContext): HtmlElement {
|
|
44
|
+
return h(
|
|
45
|
+
'section',
|
|
46
|
+
{ class: 'cg-panels', 'data-block': 'accordion' },
|
|
47
|
+
block.title === undefined
|
|
48
|
+
? null
|
|
49
|
+
: heading(
|
|
50
|
+
blockHeadingTag('accordion') ?? 'h2',
|
|
51
|
+
{ class: 'cg-panels__title', 'data-field': 'title' },
|
|
52
|
+
block.title,
|
|
53
|
+
),
|
|
54
|
+
h(
|
|
55
|
+
'ul',
|
|
56
|
+
{ class: 'cg-panels__items' },
|
|
57
|
+
block.items.map((item) => renderItem(item, ctx)),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { CollectionListBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
buildCollectionListQuery,
|
|
5
|
+
type ContentEntry,
|
|
6
|
+
entryDate,
|
|
7
|
+
entryExcerpt,
|
|
8
|
+
entryHref,
|
|
9
|
+
entryImage,
|
|
10
|
+
entryTitle,
|
|
11
|
+
type HeadingTag,
|
|
12
|
+
type HtmlElement,
|
|
13
|
+
h,
|
|
14
|
+
heading,
|
|
15
|
+
nestedHeadingTag,
|
|
16
|
+
type RenderContext,
|
|
17
|
+
renderImageSource,
|
|
18
|
+
} from '@cogenta/theme-kit'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The only block of the seventeen that reads data at render time — contract B
|
|
22
|
+
* marks it `runtime: 'server'` for that reason. The read is *not* done
|
|
23
|
+
* here: `query` builds the request from the block's own fields
|
|
24
|
+
* (`@cogenta/theme-kit`'s `buildCollectionListQuery`, shared across every
|
|
25
|
+
* theme), the caller awaits `ctx.content.list(...)` before rendering
|
|
26
|
+
* starts, and this function stays a pure function of the entries handed to
|
|
27
|
+
* it.
|
|
28
|
+
*/
|
|
29
|
+
export { buildCollectionListQuery as query }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A card in every layout: a 16:9 cover on top (`entryImage`, the same
|
|
33
|
+
* `coverImage`/`cover`/… convention every entry-carrying block on this site
|
|
34
|
+
* reads), then title and excerpt below it — the "services/insights card"
|
|
35
|
+
* register a B2B site's listing pages use, replacing the previous ledger
|
|
36
|
+
* row (a date rail beside plain text, no visual at all). An entry with no
|
|
37
|
+
* image still renders a complete card; the cover slot is simply absent
|
|
38
|
+
* rather than a placeholder box standing in for one.
|
|
39
|
+
*/
|
|
40
|
+
function renderCover(entry: ContentEntry, ctx: RenderContext): HtmlElement | null {
|
|
41
|
+
const cover = entryImage(entry, ctx)
|
|
42
|
+
if (cover === undefined) return null
|
|
43
|
+
return h(
|
|
44
|
+
'span',
|
|
45
|
+
{ class: 'cg-list__cover' },
|
|
46
|
+
renderImageSource(cover, { className: 'cg-list__cover-image', loading: 'lazy' }),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderEntry(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
51
|
+
const date = entryDate(entry)
|
|
52
|
+
const excerpt = entryExcerpt(entry)
|
|
53
|
+
return h(
|
|
54
|
+
'li',
|
|
55
|
+
{ class: 'cg-list__row' },
|
|
56
|
+
renderCover(entry, ctx),
|
|
57
|
+
h(
|
|
58
|
+
'div',
|
|
59
|
+
{ class: 'cg-list__body' },
|
|
60
|
+
heading(
|
|
61
|
+
tag,
|
|
62
|
+
{ class: 'cg-list__title' },
|
|
63
|
+
h('a', { class: 'cg-list__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
|
|
64
|
+
),
|
|
65
|
+
excerpt === undefined ? null : h('p', { class: 'cg-list__excerpt' }, excerpt),
|
|
66
|
+
date === undefined
|
|
67
|
+
? null
|
|
68
|
+
: h(
|
|
69
|
+
'time',
|
|
70
|
+
{ class: 'cg-list__date', datetime: date },
|
|
71
|
+
new Intl.DateTimeFormat(ctx.locale, { month: 'short', day: '2-digit' }).format(
|
|
72
|
+
new Date(date),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function renderCollectionList(
|
|
80
|
+
block: CollectionListBlock,
|
|
81
|
+
ctx: RenderContext,
|
|
82
|
+
entries: readonly ContentEntry[],
|
|
83
|
+
): HtmlElement {
|
|
84
|
+
const hasTitle = block.title !== undefined
|
|
85
|
+
const entryTag = nestedHeadingTag('collectionList', hasTitle)
|
|
86
|
+
const items =
|
|
87
|
+
entries.length === 0
|
|
88
|
+
? h('p', { class: 'cg-list__empty' }, ctx.t('collection.empty'))
|
|
89
|
+
: h(
|
|
90
|
+
'ul',
|
|
91
|
+
{ class: 'cg-list__items' },
|
|
92
|
+
entries.map((entry) => renderEntry(entry, ctx, entryTag)),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return h(
|
|
96
|
+
'section',
|
|
97
|
+
{
|
|
98
|
+
class: 'cg-list',
|
|
99
|
+
'data-block': 'collectionList',
|
|
100
|
+
'data-layout': block.layout,
|
|
101
|
+
},
|
|
102
|
+
hasTitle
|
|
103
|
+
? heading(
|
|
104
|
+
blockHeadingTag('collectionList') ?? 'h2',
|
|
105
|
+
{ class: 'cg-list__title-heading', 'data-field': 'title' },
|
|
106
|
+
block.title ?? '',
|
|
107
|
+
)
|
|
108
|
+
: null,
|
|
109
|
+
block.layout === 'carousel'
|
|
110
|
+
? h(
|
|
111
|
+
'div',
|
|
112
|
+
{
|
|
113
|
+
class: 'cg-list__viewport',
|
|
114
|
+
role: 'region',
|
|
115
|
+
'aria-label': block.title ?? ctx.t('collection.carousel'),
|
|
116
|
+
tabindex: '0',
|
|
117
|
+
},
|
|
118
|
+
items,
|
|
119
|
+
)
|
|
120
|
+
: items,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
* A full-width flat band, not a garish sales strip: the ask on the left and
|
|
13
|
+
* the action on the right, separated by a vertical rule on wide screens —
|
|
14
|
+
* the layout a serious B2B site uses for "talk to us", never centred copy
|
|
15
|
+
* stacked above a button. `.cg-banner__inner` re-centres the ask/action
|
|
16
|
+
* pair to the page's own measure while the flat tinted fill itself goes
|
|
17
|
+
* edge to edge (`blocks.css`).
|
|
18
|
+
*/
|
|
19
|
+
export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
|
|
20
|
+
return h(
|
|
21
|
+
'section',
|
|
22
|
+
{ class: 'cg-banner', 'data-block': 'cta' },
|
|
23
|
+
h(
|
|
24
|
+
'div',
|
|
25
|
+
{ class: 'cg-banner__inner' },
|
|
26
|
+
h(
|
|
27
|
+
'div',
|
|
28
|
+
{ class: 'cg-banner__content' },
|
|
29
|
+
heading(
|
|
30
|
+
blockHeadingTag('cta') ?? 'h2',
|
|
31
|
+
{ class: 'cg-banner__title', 'data-field': 'title' },
|
|
32
|
+
block.title,
|
|
33
|
+
),
|
|
34
|
+
block.text === undefined
|
|
35
|
+
? null
|
|
36
|
+
: h('p', { class: 'cg-banner__text', 'data-field': 'text' }, block.text),
|
|
37
|
+
),
|
|
38
|
+
h(
|
|
39
|
+
'div',
|
|
40
|
+
{ class: 'cg-banner__actions' },
|
|
41
|
+
// `actions` is required and non-empty for a `cta`, so the list is
|
|
42
|
+
// always rendered; the null branch stays reachable only through
|
|
43
|
+
// invalid data.
|
|
44
|
+
actionList(ctx, block.actions, block.title),
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
*
|
|
7
|
+
* When `consentRequired` is true the block renders a self-contained card and
|
|
8
|
+
* an outbound link — no `<iframe>`, no `<script>`, no preconnect, no poster
|
|
9
|
+
* image fetched from the provider. An embed that "only" loads a thumbnail
|
|
10
|
+
* has already handed the visitor's IP address and a cookie to the provider,
|
|
11
|
+
* which is the exact transfer consent was supposed to gate.
|
|
12
|
+
*
|
|
13
|
+
* The theme ships no consent *button*, because it ships no JavaScript:
|
|
14
|
+
* granting consent is a site-wide decision that belongs to the consent
|
|
15
|
+
* layer, not to a block. Until that layer exists, the card is the honest
|
|
16
|
+
* rendering — it says what would be loaded and lets the visitor go there
|
|
17
|
+
* deliberately. The card is framed the same way `mediaFigure` and `gallery`
|
|
18
|
+
* are (a thin bordered plate with a small provider tag), so the three read
|
|
19
|
+
* as one family of "media placeholder" treatments.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** `null` means "this provider has no embeddable frame we trust". */
|
|
23
|
+
function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
|
|
24
|
+
const url = URL.parse(rawUrl)
|
|
25
|
+
if (url === null) return null
|
|
26
|
+
const segments = url.pathname.split('/').filter((segment) => segment !== '')
|
|
27
|
+
|
|
28
|
+
switch (provider) {
|
|
29
|
+
case 'youtube': {
|
|
30
|
+
// `youtube-nocookie.com` is the privacy-preserving host, and the only
|
|
31
|
+
// one this theme will point an iframe at.
|
|
32
|
+
const id = url.hostname.endsWith('youtu.be')
|
|
33
|
+
? segments[0]
|
|
34
|
+
: (url.searchParams.get('v') ?? segments[1])
|
|
35
|
+
return id === undefined || id === null ? null : `https://www.youtube-nocookie.com/embed/${id}`
|
|
36
|
+
}
|
|
37
|
+
case 'vimeo': {
|
|
38
|
+
const id = segments.find((segment) => /^\d+$/.test(segment))
|
|
39
|
+
return id === undefined ? null : `https://player.vimeo.com/video/${id}`
|
|
40
|
+
}
|
|
41
|
+
case 'dailymotion': {
|
|
42
|
+
const id = url.hostname.endsWith('dai.ly') ? segments[0] : segments[segments.length - 1]
|
|
43
|
+
return id === undefined ? null : `https://geo.dailymotion.com/player.html?video=${id}`
|
|
44
|
+
}
|
|
45
|
+
case 'spotify': {
|
|
46
|
+
const [kind, id] = segments
|
|
47
|
+
return kind === undefined || id === undefined
|
|
48
|
+
? null
|
|
49
|
+
: `https://open.spotify.com/embed/${kind}/${id}`
|
|
50
|
+
}
|
|
51
|
+
case 'soundcloud':
|
|
52
|
+
return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
|
|
53
|
+
default:
|
|
54
|
+
// bluesky, mastodon, other: a post embed is a script tag, which this
|
|
55
|
+
// theme does not load. The link card is the rendering.
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
|
|
61
|
+
return h(
|
|
62
|
+
'div',
|
|
63
|
+
{ class: 'cg-embed__placeholder' },
|
|
64
|
+
h('p', { class: 'cg-embed__provider', 'aria-hidden': 'true' }, block.provider),
|
|
65
|
+
h('p', { class: 'cg-embed__notice' }, reason),
|
|
66
|
+
h(
|
|
67
|
+
'a',
|
|
68
|
+
{
|
|
69
|
+
class: 'cg-embed__link',
|
|
70
|
+
href: ctx.link(block.url),
|
|
71
|
+
rel: 'noopener noreferrer nofollow',
|
|
72
|
+
},
|
|
73
|
+
ctx.t('embed.open', { provider: block.provider }),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
|
|
79
|
+
const source = block.consentRequired ? null : frameSource(block.provider, block.url)
|
|
80
|
+
// Video providers are 16:9 unless the editor framed them otherwise;
|
|
81
|
+
// without a ratio the frame would collapse and shift the layout as it
|
|
82
|
+
// loads.
|
|
83
|
+
const ratio = aspectRatio(block.ratio) ?? '16 / 9'
|
|
84
|
+
|
|
85
|
+
return h(
|
|
86
|
+
'div',
|
|
87
|
+
{
|
|
88
|
+
class: 'cg-embed',
|
|
89
|
+
'data-block': 'embed',
|
|
90
|
+
'data-provider': block.provider,
|
|
91
|
+
'data-consent': block.consentRequired ? 'required' : 'not-required',
|
|
92
|
+
style: `--cg-ratio:${ratio}`,
|
|
93
|
+
},
|
|
94
|
+
source === null
|
|
95
|
+
? consentCard(
|
|
96
|
+
block,
|
|
97
|
+
ctx,
|
|
98
|
+
block.consentRequired
|
|
99
|
+
? ctx.t('embed.consentRequired', { provider: block.provider })
|
|
100
|
+
: ctx.t('embed.unsupported', { provider: block.provider }),
|
|
101
|
+
)
|
|
102
|
+
: h('iframe', {
|
|
103
|
+
class: 'cg-embed__frame',
|
|
104
|
+
src: source,
|
|
105
|
+
// An iframe with no accessible name is announced as "frame" and
|
|
106
|
+
// nothing else. WCAG 4.1.2, and the single most common embed
|
|
107
|
+
// defect.
|
|
108
|
+
title: ctx.t('embed.title', { provider: block.provider }),
|
|
109
|
+
loading: 'lazy',
|
|
110
|
+
referrerpolicy: 'strict-origin-when-cross-origin',
|
|
111
|
+
allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
|
|
112
|
+
allowfullscreen: true,
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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>` rather than a scripted accordion: expanding,
|
|
13
|
+
* keyboard operation, and the expanded state announced to assistive
|
|
14
|
+
* technology all come from the browser, at zero bytes of JavaScript.
|
|
15
|
+
*
|
|
16
|
+
* Each row carries a numeral (`01`, `02`, …) ahead of the question — the
|
|
17
|
+
* same indexing language `featureGrid` uses — and the disclosure affordance
|
|
18
|
+
* is a plus/minus mark drawn from two CSS borders, not an icon font.
|
|
19
|
+
*
|
|
20
|
+
* The question is plain text inside `<summary>`, never a heading: a heading
|
|
21
|
+
* nested in a `<summary>` renders inconsistently across screen readers, and
|
|
22
|
+
* the questions are already reachable as a list under the block's own
|
|
23
|
+
* heading.
|
|
24
|
+
*/
|
|
25
|
+
function renderItem(item: FaqItem, ctx: RenderContext, index: number): HtmlElement {
|
|
26
|
+
const number = String(index + 1).padStart(2, '0')
|
|
27
|
+
return h(
|
|
28
|
+
'li',
|
|
29
|
+
{ class: 'cg-faq__item' },
|
|
30
|
+
h(
|
|
31
|
+
'details',
|
|
32
|
+
{ class: 'cg-faq__details' },
|
|
33
|
+
h(
|
|
34
|
+
'summary',
|
|
35
|
+
{ class: 'cg-faq__question' },
|
|
36
|
+
h('span', { class: 'cg-faq__index', 'aria-hidden': 'true' }, number),
|
|
37
|
+
h('span', { class: 'cg-faq__question-text' }, item.question),
|
|
38
|
+
),
|
|
39
|
+
h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
|
|
45
|
+
return h(
|
|
46
|
+
'section',
|
|
47
|
+
{ class: 'cg-faq', 'data-block': 'faq' },
|
|
48
|
+
block.title === undefined
|
|
49
|
+
? null
|
|
50
|
+
: heading(
|
|
51
|
+
blockHeadingTag('faq') ?? 'h2',
|
|
52
|
+
{ class: 'cg-faq__title', 'data-field': 'title' },
|
|
53
|
+
block.title,
|
|
54
|
+
),
|
|
55
|
+
h(
|
|
56
|
+
'ul',
|
|
57
|
+
{ class: 'cg-faq__items' },
|
|
58
|
+
block.items.map((item, index) => renderItem(item, ctx, index)),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
* This theme's "our services" section — a card grid, each card led by a
|
|
16
|
+
* real inline glyph (`renderIcon`) inside a flat, accent-tinted square, the
|
|
17
|
+
* B2B "capability card" register a consultancy or software vendor's
|
|
18
|
+
* services page uses, replacing the numbered ledger rows this block used to
|
|
19
|
+
* render (a plain list read as a table of contents, not as a services
|
|
20
|
+
* pitch a visitor scans in one pass).
|
|
21
|
+
*
|
|
22
|
+
* Capped at three columns on a wide screen — a six-item grid staying 3×2
|
|
23
|
+
* rather than drifting to four columns plus two orphans, the same
|
|
24
|
+
* discipline `theme-saas`'s own `featureGrid` holds.
|
|
25
|
+
*
|
|
26
|
+
* As in every other theme, the item's title carries the link so the link's
|
|
27
|
+
* accessible name is the feature's own name (WCAG 2.4.4) — the block has no
|
|
28
|
+
* separate label field to write a "learn more" with anyway.
|
|
29
|
+
*/
|
|
30
|
+
function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
31
|
+
const title =
|
|
32
|
+
item.link === undefined
|
|
33
|
+
? heading(tag, { class: 'cg-service__title' }, item.title)
|
|
34
|
+
: heading(
|
|
35
|
+
tag,
|
|
36
|
+
{ class: 'cg-service__title' },
|
|
37
|
+
h('a', { class: 'cg-service__link', href: href(ctx, item.link) }, item.title),
|
|
38
|
+
)
|
|
39
|
+
return h(
|
|
40
|
+
'li',
|
|
41
|
+
{ class: 'cg-service' },
|
|
42
|
+
item.icon === undefined
|
|
43
|
+
? null
|
|
44
|
+
: h(
|
|
45
|
+
'span',
|
|
46
|
+
{ class: 'cg-service__icon', 'data-icon': item.icon, 'aria-hidden': 'true' },
|
|
47
|
+
renderIcon(item.icon),
|
|
48
|
+
),
|
|
49
|
+
title,
|
|
50
|
+
item.text === undefined ? null : h('p', { class: 'cg-service__text' }, item.text),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
|
|
55
|
+
const hasTitle = block.title !== undefined
|
|
56
|
+
const itemTag = nestedHeadingTag('featureGrid', hasTitle)
|
|
57
|
+
return h(
|
|
58
|
+
'section',
|
|
59
|
+
{ class: 'cg-services', 'data-block': 'featureGrid' },
|
|
60
|
+
hasTitle
|
|
61
|
+
? heading(
|
|
62
|
+
blockHeadingTag('featureGrid') ?? 'h2',
|
|
63
|
+
{ class: 'cg-services__title', 'data-field': 'title' },
|
|
64
|
+
block.title ?? '',
|
|
65
|
+
)
|
|
66
|
+
: null,
|
|
67
|
+
h(
|
|
68
|
+
'ul',
|
|
69
|
+
{ class: 'cg-services__items' },
|
|
70
|
+
block.items.map((item) => renderItem(item, ctx, itemTag)),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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**. A scroll-snapping list gives touch swiping,
|
|
6
|
+
* trackpad scrolling and — because the list is focusable and labelled —
|
|
7
|
+
* arrow-key scrolling, with none of the failure modes of a scripted
|
|
8
|
+
* carousel: no auto-advance to fight (WCAG 2.2.2), no focus stolen from an
|
|
9
|
+
* off-screen slide, and it still works before any styling loads.
|
|
10
|
+
*
|
|
11
|
+
* Each tile is a thin-bordered plate rather than a shadowed, fully-rounded
|
|
12
|
+
* card — consistent with the framed treatment `mediaFigure` and `embed`
|
|
13
|
+
* use, so a page mixing all three reads as one system.
|
|
14
|
+
*/
|
|
15
|
+
export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
|
|
16
|
+
const items = h(
|
|
17
|
+
'ul',
|
|
18
|
+
{ class: 'cg-gallery__items' },
|
|
19
|
+
block.items.map((item) =>
|
|
20
|
+
h(
|
|
21
|
+
'li',
|
|
22
|
+
{ class: 'cg-gallery__item' },
|
|
23
|
+
image(ctx, item.media, { sizes: '(min-width: 48rem) 22rem, 60vw' }),
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return h(
|
|
29
|
+
'section',
|
|
30
|
+
{
|
|
31
|
+
class: 'cg-gallery',
|
|
32
|
+
'data-block': 'gallery',
|
|
33
|
+
'data-layout': block.layout,
|
|
34
|
+
},
|
|
35
|
+
block.layout === 'carousel'
|
|
36
|
+
? h(
|
|
37
|
+
'div',
|
|
38
|
+
{
|
|
39
|
+
class: 'cg-gallery__viewport',
|
|
40
|
+
role: 'region',
|
|
41
|
+
'aria-label': ctx.t('gallery.carousel'),
|
|
42
|
+
tabindex: '0',
|
|
43
|
+
},
|
|
44
|
+
items,
|
|
45
|
+
)
|
|
46
|
+
: items,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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 hero carries the page's `h1` (contract B: `headingLevel: 'h1'`), so
|
|
14
|
+
* `renderPage` relies on it to avoid emitting a second one.
|
|
15
|
+
*
|
|
16
|
+
* Structurally distinct from a centred/split "marketing" hero: the eyebrow,
|
|
17
|
+
* title, subtitle and actions sit in a single left-aligned column with a
|
|
18
|
+
* vertical accent rule (`cg-hero__mark`) standing beside the copy — the
|
|
19
|
+
* "confident report cover" read the aesthetic direction asks for — while the
|
|
20
|
+
* media sits in its own bordered frame with corner marks, never behind a
|
|
21
|
+
* decorative gradient wash.
|
|
22
|
+
*/
|
|
23
|
+
export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
|
|
24
|
+
const tag = blockHeadingTag('hero') ?? 'h1'
|
|
25
|
+
return h(
|
|
26
|
+
'section',
|
|
27
|
+
{ class: 'cg-hero', 'data-block': 'hero' },
|
|
28
|
+
h(
|
|
29
|
+
'div',
|
|
30
|
+
{ class: 'cg-hero__intro' },
|
|
31
|
+
h('span', { class: 'cg-hero__mark', 'aria-hidden': 'true' }),
|
|
32
|
+
h(
|
|
33
|
+
'div',
|
|
34
|
+
{ class: 'cg-hero__copy' },
|
|
35
|
+
block.eyebrow === undefined
|
|
36
|
+
? null
|
|
37
|
+
: h('p', { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
|
|
38
|
+
heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
|
|
39
|
+
block.subtitle === undefined
|
|
40
|
+
? null
|
|
41
|
+
: h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
|
|
42
|
+
actionList(ctx, block.actions, ctx.t('hero.actions')),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
block.media === undefined
|
|
46
|
+
? null
|
|
47
|
+
: h(
|
|
48
|
+
'div',
|
|
49
|
+
{ class: 'cg-hero__frame' },
|
|
50
|
+
// The only image above the fold by construction, so the only one
|
|
51
|
+
// that must not be lazy — a lazy-loaded LCP element is a measured
|
|
52
|
+
// Lighthouse regression, not a theoretical one.
|
|
53
|
+
image(ctx, block.media, {
|
|
54
|
+
className: 'cg-hero__media',
|
|
55
|
+
loading: 'eager',
|
|
56
|
+
sizes: '(min-width: 64rem) 46vw, 100vw',
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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" / social-proof row
|
|
6
|
+
* next to `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` already falls back to when a link is absent.
|
|
9
|
+
*
|
|
10
|
+
* Unlike `logos`'s held-back-until-hovered grayscale treatment, this strip
|
|
11
|
+
* renders every mark at full colour and even weight in a single row — the
|
|
12
|
+
* quieter, denser "as seen in" convention, distinct from the client-cards
|
|
13
|
+
* feel `logos` is built for.
|
|
14
|
+
*/
|
|
15
|
+
function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
|
|
16
|
+
return h(
|
|
17
|
+
'li',
|
|
18
|
+
{ class: 'cg-logo-band__item' },
|
|
19
|
+
image(ctx, item.media, { className: 'cg-logo-band__image', variant: { fit: 'contain' } }),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
|
|
24
|
+
return h(
|
|
25
|
+
'figure',
|
|
26
|
+
{ class: 'cg-logo-band', 'data-block': 'logoStrip' },
|
|
27
|
+
h(
|
|
28
|
+
'ul',
|
|
29
|
+
{ class: 'cg-logo-band__items' },
|
|
30
|
+
block.logos.map((item) => renderItem(item, ctx)),
|
|
31
|
+
),
|
|
32
|
+
block.caption === undefined
|
|
33
|
+
? null
|
|
34
|
+
: h('figcaption', { class: 'cg-logo-band__caption', 'data-field': 'caption' }, block.caption),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
* A "trusted by" client strip. Contract B says the organisation's name **is**
|
|
13
|
+
* the accessible name of the link, so the logo image carries it as `alt`
|
|
14
|
+
* text when the media entity has none, and no visually hidden duplicate is
|
|
15
|
+
* added: a link whose only content is an image with `alt` text is already
|
|
16
|
+
* named.
|
|
17
|
+
*
|
|
18
|
+
* The restrained, grayscale-leaning treatment (`filter: grayscale()` in the
|
|
19
|
+
* stylesheet) is the "held back until hovered" client-logo language the
|
|
20
|
+
* aesthetic direction calls for — a `filter`, never `grayscale` baked into
|
|
21
|
+
* the media itself, since the underlying asset still arrives as a real
|
|
22
|
+
* `<img>` from `image()`.
|
|
23
|
+
*/
|
|
24
|
+
function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
|
|
25
|
+
const logo = image(ctx, item.media, {
|
|
26
|
+
className: 'cg-clients__logo',
|
|
27
|
+
altFrom: item.name,
|
|
28
|
+
variant: { fit: 'contain' },
|
|
29
|
+
})
|
|
30
|
+
return h(
|
|
31
|
+
'li',
|
|
32
|
+
{ class: 'cg-clients__item' },
|
|
33
|
+
item.url === undefined
|
|
34
|
+
? logo
|
|
35
|
+
: h(
|
|
36
|
+
'a',
|
|
37
|
+
{ class: 'cg-clients__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
|
|
38
|
+
logo,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
|
|
44
|
+
return h(
|
|
45
|
+
'section',
|
|
46
|
+
{ class: 'cg-clients', 'data-block': 'logos' },
|
|
47
|
+
block.title === undefined
|
|
48
|
+
? null
|
|
49
|
+
: heading(
|
|
50
|
+
blockHeadingTag('logos') ?? 'h2',
|
|
51
|
+
{ class: 'cg-clients__title', 'data-field': 'title' },
|
|
52
|
+
block.title,
|
|
53
|
+
),
|
|
54
|
+
h(
|
|
55
|
+
'ul',
|
|
56
|
+
{ class: 'cg-clients__items' },
|
|
57
|
+
block.items.map((item) => renderItem(item, ctx)),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
}
|