@cogenta/theme-portfolio 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 +17 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +20 -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 +25 -0
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
- package/dist/src/render/blocks/collection-list.js +77 -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 +16 -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 +88 -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 +23 -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 +30 -0
- package/dist/src/render/blocks/feature-grid.js.map +1 -0
- package/dist/src/render/blocks/gallery.d.ts +18 -0
- package/dist/src/render/blocks/gallery.d.ts.map +1 -0
- package/dist/src/render/blocks/gallery.js +34 -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 +23 -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 +28 -0
- package/dist/src/render/blocks/logos.js.map +1 -0
- package/dist/src/render/blocks/media-figure.d.ts +19 -0
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
- package/dist/src/render/blocks/media-figure.js +36 -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 +29 -0
- package/dist/src/render/blocks/pricing-table.js.map +1 -0
- package/dist/src/render/blocks/prose.d.ts +16 -0
- package/dist/src/render/blocks/prose.d.ts.map +1 -0
- package/dist/src/render/blocks/prose.js +17 -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 +18 -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 +22 -0
- package/dist/src/render/blocks/stats.js.map +1 -0
- package/dist/src/render/blocks/testimonial.d.ts +20 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +29 -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 +114 -0
- package/dist/src/render/chrome.js.map +1 -0
- package/dist/src/render/render-block.d.ts +12 -0
- package/dist/src/render/render-block.d.ts.map +1 -0
- package/dist/src/render/render-block.js +111 -0
- package/dist/src/render/render-block.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +14 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +36 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/theme.config.d.ts +26 -0
- package/dist/theme.config.d.ts.map +1 -0
- package/dist/theme.config.js +56 -0
- package/dist/theme.config.js.map +1 -0
- package/package.json +57 -0
- package/src/index.ts +19 -0
- package/src/render/blocks/accordion.ts +64 -0
- package/src/render/blocks/collection-list.ts +181 -0
- package/src/render/blocks/cta.ts +37 -0
- package/src/render/blocks/embed.ts +111 -0
- package/src/render/blocks/faq.ts +54 -0
- package/src/render/blocks/feature-grid.ts +68 -0
- package/src/render/blocks/gallery.ts +55 -0
- package/src/render/blocks/hero.ts +55 -0
- package/src/render/blocks/logo-strip.ts +38 -0
- package/src/render/blocks/logos.ts +63 -0
- package/src/render/blocks/media-figure.ts +51 -0
- package/src/render/blocks/pricing-table.ts +69 -0
- package/src/render/blocks/prose.ts +22 -0
- package/src/render/blocks/quote.ts +48 -0
- package/src/render/blocks/stat-counter.ts +49 -0
- package/src/render/blocks/stats.ts +49 -0
- package/src/render/blocks/testimonial.ts +45 -0
- package/src/render/chrome.ts +142 -0
- package/src/render/render-block.ts +155 -0
- package/src/render/term-archive.ts +88 -0
- package/src/styles/archive.css +40 -0
- package/src/styles/base.css +1056 -0
- package/src/styles/blocks.css +1468 -0
- package/src/styles/theme.css +20 -0
- package/src/styles/tokens.css +363 -0
- package/tokens.json +36 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The shared contract D primitives every theme package implements against —
|
|
2
|
+
// re-exported so a consumer of this package never has to know they live in
|
|
3
|
+
// `@cogenta/theme-kit` to use them alongside this theme's own blocks.
|
|
4
|
+
export * from '@cogenta/theme-kit'
|
|
5
|
+
export * from './render/blocks/collection-list.js'
|
|
6
|
+
export * from './render/blocks/cta.js'
|
|
7
|
+
export * from './render/blocks/embed.js'
|
|
8
|
+
export * from './render/blocks/faq.js'
|
|
9
|
+
export * from './render/blocks/feature-grid.js'
|
|
10
|
+
export * from './render/blocks/gallery.js'
|
|
11
|
+
export * from './render/blocks/hero.js'
|
|
12
|
+
export * from './render/blocks/logos.js'
|
|
13
|
+
export * from './render/blocks/media-figure.js'
|
|
14
|
+
export * from './render/blocks/prose.js'
|
|
15
|
+
export * from './render/blocks/quote.js'
|
|
16
|
+
export * from './render/blocks/stats.js'
|
|
17
|
+
export * from './render/chrome.js'
|
|
18
|
+
export * from './render/render-block.js'
|
|
19
|
+
export * from './render/term-archive.js'
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
* Same zero-JS mechanism as `faq.ts` — `<details>`/`<summary>` gives
|
|
13
|
+
* expand/collapse, keyboard operation and the `[open]` state to assistive
|
|
14
|
+
* technology for free — but with markup and class names of its own
|
|
15
|
+
* (`cg-accordion__*`, never `cg-faq__*`) so the two blocks can diverge
|
|
16
|
+
* visually later without one quietly dragging the other along. RFC 0001
|
|
17
|
+
* treats them as two distinct editorial intents sharing one data shape; this
|
|
18
|
+
* package keeps that distinction in its markup too.
|
|
19
|
+
*
|
|
20
|
+
* The marker here is a plain rotating caret rather than `faq.ts`'s plus/
|
|
21
|
+
* cross, and questions sit in a numbered ledger the same way `logos.ts`'s
|
|
22
|
+
* items do — an accordion reads as an indexed list of topics, not a wall of
|
|
23
|
+
* frequently-asked questions.
|
|
24
|
+
*/
|
|
25
|
+
function renderItem(item: AccordionItem, ctx: RenderContext, index: number): HtmlElement {
|
|
26
|
+
return h(
|
|
27
|
+
'li',
|
|
28
|
+
{ class: 'cg-accordion__item' },
|
|
29
|
+
h(
|
|
30
|
+
'details',
|
|
31
|
+
{ class: 'cg-accordion__details' },
|
|
32
|
+
h(
|
|
33
|
+
'summary',
|
|
34
|
+
{ class: 'cg-accordion__question' },
|
|
35
|
+
h(
|
|
36
|
+
'span',
|
|
37
|
+
{ class: 'cg-accordion__index', 'aria-hidden': 'true' },
|
|
38
|
+
String(index + 1).padStart(2, '0'),
|
|
39
|
+
),
|
|
40
|
+
h('span', { class: 'cg-accordion__label' }, item.question),
|
|
41
|
+
),
|
|
42
|
+
h('div', { class: 'cg-accordion__answer' }, renderRichText(ctx, item.answer)),
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderAccordion(block: AccordionBlock, ctx: RenderContext): HtmlElement {
|
|
48
|
+
return h(
|
|
49
|
+
'section',
|
|
50
|
+
{ class: 'cg-block cg-accordion', 'data-block': 'accordion' },
|
|
51
|
+
block.title === undefined
|
|
52
|
+
? null
|
|
53
|
+
: heading(
|
|
54
|
+
blockHeadingTag('accordion') ?? 'h2',
|
|
55
|
+
{ class: 'cg-accordion__title', 'data-field': 'title' },
|
|
56
|
+
block.title,
|
|
57
|
+
),
|
|
58
|
+
h(
|
|
59
|
+
'ul',
|
|
60
|
+
{ class: 'cg-accordion__items' },
|
|
61
|
+
block.items.map((item, index) => renderItem(item, ctx, index)),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
|
22
|
+
* B marks it `runtime: 'server'` for that reason.
|
|
23
|
+
*
|
|
24
|
+
* The read is *not* done here. `query` builds the request
|
|
25
|
+
* (`@cogenta/theme-kit`'s `buildCollectionListQuery`, shared across every
|
|
26
|
+
* theme since it derives purely from the block's own fields), the caller
|
|
27
|
+
* awaits `ctx.content.list(...)` before rendering starts, and this function
|
|
28
|
+
* stays a pure function of the entries it is handed.
|
|
29
|
+
*
|
|
30
|
+
* `list` keeps this theme's original register — a numbered mono index, the
|
|
31
|
+
* title set at feature-headline scale, the date pushed to the far edge —
|
|
32
|
+
* unchanged since L23. `grid`/`carousel` (`theme@1.4`) retile the same
|
|
33
|
+
* entries into full-bleed project cards instead: a cover (`entryImage`,
|
|
34
|
+
* 4:3), the title in display type, and — read straight off the raw
|
|
35
|
+
* `ContentEntry` this block's entries already are, never invented — the
|
|
36
|
+
* entry's own `role`/`year` fields as a meta line when a collection happens
|
|
37
|
+
* to declare them (the `portfolio` blueprint's `project` does; any other
|
|
38
|
+
* collection simply shows no meta line).
|
|
39
|
+
*/
|
|
40
|
+
export { buildCollectionListQuery as query }
|
|
41
|
+
|
|
42
|
+
function rawText(entry: ContentEntry, field: string): string | undefined {
|
|
43
|
+
const value = entry[field]
|
|
44
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `role`/`year`, read straight off the entry — the "raw field" meta line a grid card shows when the collection behind it has them. */
|
|
48
|
+
function renderCardMeta(entry: ContentEntry): HtmlElement | null {
|
|
49
|
+
const role = rawText(entry, 'role')
|
|
50
|
+
const year = rawText(entry, 'year')
|
|
51
|
+
if (role === undefined && year === undefined) return null
|
|
52
|
+
return h(
|
|
53
|
+
'p',
|
|
54
|
+
{ class: 'cg-collection__meta' },
|
|
55
|
+
role === undefined ? null : h('span', { class: 'cg-collection__meta-role' }, role),
|
|
56
|
+
year === undefined ? null : h('span', { class: 'cg-collection__meta-year' }, year),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderCard(
|
|
61
|
+
entry: ContentEntry,
|
|
62
|
+
ctx: RenderContext,
|
|
63
|
+
tag: HeadingTag,
|
|
64
|
+
index: number,
|
|
65
|
+
): HtmlElement {
|
|
66
|
+
const cover = entryImage(entry, ctx, { width: 900, height: 675, fit: 'cover' })
|
|
67
|
+
return h(
|
|
68
|
+
'li',
|
|
69
|
+
{ class: 'cg-entry cg-entry--card' },
|
|
70
|
+
h(
|
|
71
|
+
'article',
|
|
72
|
+
{ class: 'cg-entry__body' },
|
|
73
|
+
h(
|
|
74
|
+
'a',
|
|
75
|
+
{ class: 'cg-entry__cover-link', href: entryHref(entry, ctx), tabindex: -1 },
|
|
76
|
+
cover === undefined
|
|
77
|
+
? h('span', { class: 'cg-entry__cover-empty', 'aria-hidden': 'true' })
|
|
78
|
+
: renderImageSource(cover, {
|
|
79
|
+
className: 'cg-entry__cover',
|
|
80
|
+
sizes: '(min-width: 80rem) 50vw, 100vw',
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
h(
|
|
84
|
+
'span',
|
|
85
|
+
{ class: 'cg-entry__index', 'aria-hidden': 'true' },
|
|
86
|
+
String(index + 1).padStart(2, '0'),
|
|
87
|
+
),
|
|
88
|
+
heading(
|
|
89
|
+
tag,
|
|
90
|
+
{ class: 'cg-entry__title' },
|
|
91
|
+
h('a', { class: 'cg-entry__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
|
|
92
|
+
),
|
|
93
|
+
renderCardMeta(entry),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderRow(
|
|
99
|
+
entry: ContentEntry,
|
|
100
|
+
ctx: RenderContext,
|
|
101
|
+
tag: HeadingTag,
|
|
102
|
+
index: number,
|
|
103
|
+
): HtmlElement {
|
|
104
|
+
const date = entryDate(entry)
|
|
105
|
+
const excerpt = entryExcerpt(entry)
|
|
106
|
+
return h(
|
|
107
|
+
'li',
|
|
108
|
+
{ class: 'cg-entry' },
|
|
109
|
+
h(
|
|
110
|
+
'article',
|
|
111
|
+
{ class: 'cg-entry__body' },
|
|
112
|
+
h(
|
|
113
|
+
'span',
|
|
114
|
+
{ class: 'cg-entry__index', 'aria-hidden': 'true' },
|
|
115
|
+
String(index + 1).padStart(2, '0'),
|
|
116
|
+
),
|
|
117
|
+
heading(
|
|
118
|
+
tag,
|
|
119
|
+
{ class: 'cg-entry__title' },
|
|
120
|
+
h('a', { class: 'cg-entry__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
|
|
121
|
+
),
|
|
122
|
+
date === undefined
|
|
123
|
+
? null
|
|
124
|
+
: h(
|
|
125
|
+
'time',
|
|
126
|
+
{ class: 'cg-entry__date', datetime: date },
|
|
127
|
+
// Formatted in the rendered locale rather than left as an ISO
|
|
128
|
+
// string: the machine-readable form is already in `datetime`.
|
|
129
|
+
new Intl.DateTimeFormat(ctx.locale, { dateStyle: 'long' }).format(new Date(date)),
|
|
130
|
+
),
|
|
131
|
+
excerpt === undefined ? null : h('p', { class: 'cg-entry__excerpt' }, excerpt),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function renderCollectionList(
|
|
137
|
+
block: CollectionListBlock,
|
|
138
|
+
ctx: RenderContext,
|
|
139
|
+
entries: readonly ContentEntry[],
|
|
140
|
+
): HtmlElement {
|
|
141
|
+
const hasTitle = block.title !== undefined
|
|
142
|
+
const entryTag = nestedHeadingTag('collectionList', hasTitle)
|
|
143
|
+
const isTiled = block.layout === 'grid' || block.layout === 'carousel'
|
|
144
|
+
const renderItem = isTiled ? renderCard : renderRow
|
|
145
|
+
const items =
|
|
146
|
+
entries.length === 0
|
|
147
|
+
? h('p', { class: 'cg-collection__empty' }, ctx.t('collection.empty'))
|
|
148
|
+
: h(
|
|
149
|
+
'ol',
|
|
150
|
+
{ class: 'cg-collection__items' },
|
|
151
|
+
entries.map((entry, index) => renderItem(entry, ctx, entryTag, index)),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return h(
|
|
155
|
+
'section',
|
|
156
|
+
{
|
|
157
|
+
class: 'cg-block cg-collection',
|
|
158
|
+
'data-block': 'collectionList',
|
|
159
|
+
'data-layout': block.layout,
|
|
160
|
+
},
|
|
161
|
+
hasTitle
|
|
162
|
+
? heading(
|
|
163
|
+
blockHeadingTag('collectionList') ?? 'h2',
|
|
164
|
+
{ class: 'cg-collection__title', 'data-field': 'title' },
|
|
165
|
+
block.title ?? '',
|
|
166
|
+
)
|
|
167
|
+
: null,
|
|
168
|
+
block.layout === 'carousel'
|
|
169
|
+
? h(
|
|
170
|
+
'div',
|
|
171
|
+
{
|
|
172
|
+
class: 'cg-collection__viewport',
|
|
173
|
+
role: 'region',
|
|
174
|
+
'aria-label': block.title ?? ctx.t('collection.carousel'),
|
|
175
|
+
tabindex: '0',
|
|
176
|
+
},
|
|
177
|
+
items,
|
|
178
|
+
)
|
|
179
|
+
: items,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
* The one panel on the page that inverts the palette — ink background, paper
|
|
13
|
+
* text — so it reads as a full stop rather than another section. The title
|
|
14
|
+
* is set at the same display scale as the hero: a call to action on a
|
|
15
|
+
* portfolio site is a second headline, not a footnote.
|
|
16
|
+
*/
|
|
17
|
+
export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
|
|
18
|
+
return h(
|
|
19
|
+
'section',
|
|
20
|
+
{ class: 'cg-block cg-cta', 'data-block': 'cta' },
|
|
21
|
+
h(
|
|
22
|
+
'div',
|
|
23
|
+
{ class: 'cg-cta__frame' },
|
|
24
|
+
heading(
|
|
25
|
+
blockHeadingTag('cta') ?? 'h2',
|
|
26
|
+
{ class: 'cg-cta__title', 'data-field': 'title' },
|
|
27
|
+
block.title,
|
|
28
|
+
),
|
|
29
|
+
block.text === undefined
|
|
30
|
+
? null
|
|
31
|
+
: h('p', { class: 'cg-cta__text', 'data-field': 'text' }, block.text),
|
|
32
|
+
// `actions` is required and non-empty for a `cta`, so the list is always
|
|
33
|
+
// rendered; the null branch stays reachable only through invalid data.
|
|
34
|
+
actionList(ctx, block.actions, block.title),
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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 tag, no preconnect, no poster
|
|
9
|
+
* image fetched from the provider. An embed that "only" loads a thumbnail has
|
|
10
|
+
* 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. The card carries a bracketed provider tag in the
|
|
16
|
+
* mono register — `[ youtube ]` — the theme's own visual shorthand for
|
|
17
|
+
* "something external belongs here".
|
|
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
|
+
// `youtube-nocookie.com` is the privacy-preserving host, and the only one
|
|
29
|
+
// this theme will point an iframe at.
|
|
30
|
+
const id = url.hostname.endsWith('youtu.be')
|
|
31
|
+
? segments[0]
|
|
32
|
+
: (url.searchParams.get('v') ?? segments[1])
|
|
33
|
+
return id === undefined || id === null ? null : `https://www.youtube-nocookie.com/embed/${id}`
|
|
34
|
+
}
|
|
35
|
+
case 'vimeo': {
|
|
36
|
+
const id = segments.find((segment) => /^\d+$/.test(segment))
|
|
37
|
+
return id === undefined ? null : `https://player.vimeo.com/video/${id}`
|
|
38
|
+
}
|
|
39
|
+
case 'dailymotion': {
|
|
40
|
+
const id = url.hostname.endsWith('dai.ly') ? segments[0] : segments[segments.length - 1]
|
|
41
|
+
return id === undefined ? null : `https://geo.dailymotion.com/player.html?video=${id}`
|
|
42
|
+
}
|
|
43
|
+
case 'spotify': {
|
|
44
|
+
const [kind, id] = segments
|
|
45
|
+
return kind === undefined || id === undefined
|
|
46
|
+
? null
|
|
47
|
+
: `https://open.spotify.com/embed/${kind}/${id}`
|
|
48
|
+
}
|
|
49
|
+
case 'soundcloud':
|
|
50
|
+
return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
|
|
51
|
+
default:
|
|
52
|
+
// bluesky, mastodon, other: a post embed is a script tag, which this
|
|
53
|
+
// theme does not load. The link card is the rendering.
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
|
|
59
|
+
return h(
|
|
60
|
+
'div',
|
|
61
|
+
{ class: 'cg-embed__placeholder' },
|
|
62
|
+
h('span', { class: 'cg-embed__tag' }, `[ ${block.provider} ]`),
|
|
63
|
+
h('p', { class: 'cg-embed__notice' }, reason),
|
|
64
|
+
h(
|
|
65
|
+
'a',
|
|
66
|
+
{
|
|
67
|
+
class: 'cg-embed__link',
|
|
68
|
+
href: ctx.link(block.url),
|
|
69
|
+
rel: 'noopener noreferrer nofollow',
|
|
70
|
+
},
|
|
71
|
+
ctx.t('embed.open', { provider: block.provider }),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
|
|
77
|
+
const source = block.consentRequired ? null : frameSource(block.provider, block.url)
|
|
78
|
+
// Video providers are 16:9 unless the editor framed them otherwise; without a
|
|
79
|
+
// ratio the frame would collapse and shift the layout as it loads.
|
|
80
|
+
const ratio = aspectRatio(block.ratio) ?? '16 / 9'
|
|
81
|
+
|
|
82
|
+
return h(
|
|
83
|
+
'div',
|
|
84
|
+
{
|
|
85
|
+
class: 'cg-block cg-embed',
|
|
86
|
+
'data-block': 'embed',
|
|
87
|
+
'data-provider': block.provider,
|
|
88
|
+
'data-consent': block.consentRequired ? 'required' : 'not-required',
|
|
89
|
+
style: `--cg-ratio:${ratio}`,
|
|
90
|
+
},
|
|
91
|
+
source === null
|
|
92
|
+
? consentCard(
|
|
93
|
+
block,
|
|
94
|
+
ctx,
|
|
95
|
+
block.consentRequired
|
|
96
|
+
? ctx.t('embed.consentRequired', { provider: block.provider })
|
|
97
|
+
: ctx.t('embed.unsupported', { provider: block.provider }),
|
|
98
|
+
)
|
|
99
|
+
: h('iframe', {
|
|
100
|
+
class: 'cg-embed__frame',
|
|
101
|
+
src: source,
|
|
102
|
+
// An iframe with no accessible name is announced as "frame" and
|
|
103
|
+
// nothing else. WCAG 4.1.2, and the single most common embed defect.
|
|
104
|
+
title: ctx.t('embed.title', { provider: block.provider }),
|
|
105
|
+
loading: 'lazy',
|
|
106
|
+
referrerpolicy: 'strict-origin-when-cross-origin',
|
|
107
|
+
allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
|
|
108
|
+
allowfullscreen: true,
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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, the expanded state announced to assistive technology
|
|
14
|
+
* and in-page search all come from the browser, at zero bytes of
|
|
15
|
+
* JavaScript.
|
|
16
|
+
*
|
|
17
|
+
* The question is plain text inside `<summary>`, not a heading: a heading
|
|
18
|
+
* nested in a summary is rendered inconsistently across screen readers, and
|
|
19
|
+
* the questions are already reachable as a list under the block's own
|
|
20
|
+
* heading. A CSS-drawn plus mark (rotated to a cross on `[open]`) replaces
|
|
21
|
+
* the native marker rather than a chevron — the theme's own vocabulary of
|
|
22
|
+
* shapes, kept purely visual.
|
|
23
|
+
*/
|
|
24
|
+
function renderItem(item: FaqItem, ctx: RenderContext): HtmlElement {
|
|
25
|
+
return h(
|
|
26
|
+
'li',
|
|
27
|
+
{ class: 'cg-faq__item' },
|
|
28
|
+
h(
|
|
29
|
+
'details',
|
|
30
|
+
{ class: 'cg-faq__details' },
|
|
31
|
+
h('summary', { class: 'cg-faq__question' }, h('span', {}, item.question)),
|
|
32
|
+
h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
|
|
38
|
+
return h(
|
|
39
|
+
'section',
|
|
40
|
+
{ class: 'cg-block cg-faq', 'data-block': 'faq' },
|
|
41
|
+
block.title === undefined
|
|
42
|
+
? null
|
|
43
|
+
: heading(
|
|
44
|
+
blockHeadingTag('faq') ?? 'h2',
|
|
45
|
+
{ class: 'cg-faq__title', 'data-field': 'title' },
|
|
46
|
+
block.title,
|
|
47
|
+
),
|
|
48
|
+
h(
|
|
49
|
+
'ul',
|
|
50
|
+
{ class: 'cg-faq__items' },
|
|
51
|
+
block.items.map((item) => renderItem(item, ctx)),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
* The item's title is the link, so the link's accessible name is the
|
|
16
|
+
* feature's name — a bare "learn more" repeated across a grid is the
|
|
17
|
+
* classic failure of WCAG 2.4.4, and the block carries no label field to
|
|
18
|
+
* write one anyway.
|
|
19
|
+
*
|
|
20
|
+
* `icon` names a symbol (`renderIcon`, `theme@1.4`) — drawn small and
|
|
21
|
+
* accent-coloured, decorative (`aria-hidden`, the title already carries the
|
|
22
|
+
* accessible name) — sitting *above* the running index numeral this theme
|
|
23
|
+
* has always drawn from a CSS counter, never replacing it: the numeral is
|
|
24
|
+
* this theme's own editorial device, the icon is the shared vocabulary's.
|
|
25
|
+
* An item with no icon, or one whose name this theme does not recognise,
|
|
26
|
+
* keeps exactly the pre-1.4 numeral-only card.
|
|
27
|
+
*/
|
|
28
|
+
function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
29
|
+
const title =
|
|
30
|
+
item.link === undefined
|
|
31
|
+
? heading(tag, { class: 'cg-feature__title' }, item.title)
|
|
32
|
+
: heading(
|
|
33
|
+
tag,
|
|
34
|
+
{ class: 'cg-feature__title' },
|
|
35
|
+
h('a', { class: 'cg-feature__link', href: href(ctx, item.link) }, item.title),
|
|
36
|
+
)
|
|
37
|
+
const icon =
|
|
38
|
+
item.icon === undefined ? null : renderIcon(item.icon, { className: 'cg-feature__icon' })
|
|
39
|
+
return h(
|
|
40
|
+
'li',
|
|
41
|
+
{ class: 'cg-feature', 'data-icon': item.icon },
|
|
42
|
+
icon,
|
|
43
|
+
h('span', { class: 'cg-feature__index', 'aria-hidden': 'true' }),
|
|
44
|
+
title,
|
|
45
|
+
item.text === undefined ? null : h('p', { class: 'cg-feature__text' }, item.text),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
|
|
50
|
+
const hasTitle = block.title !== undefined
|
|
51
|
+
const itemTag = nestedHeadingTag('featureGrid', hasTitle)
|
|
52
|
+
return h(
|
|
53
|
+
'section',
|
|
54
|
+
{ class: 'cg-block cg-features', 'data-block': 'featureGrid' },
|
|
55
|
+
hasTitle
|
|
56
|
+
? heading(
|
|
57
|
+
blockHeadingTag('featureGrid') ?? 'h2',
|
|
58
|
+
{ class: 'cg-features__title', 'data-field': 'title' },
|
|
59
|
+
block.title ?? '',
|
|
60
|
+
)
|
|
61
|
+
: null,
|
|
62
|
+
h(
|
|
63
|
+
'ul',
|
|
64
|
+
{ class: 'cg-features__items' },
|
|
65
|
+
block.items.map((item) => renderItem(item, ctx, itemTag)),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { GalleryBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The carousel ships **no JavaScript**. It is a scroll-snapping list, which
|
|
6
|
+
* gives touch swiping, trackpad scrolling and — because the list is focusable
|
|
7
|
+
* and labelled — arrow-key scrolling, with none of the failure modes of a
|
|
8
|
+
* scripted carousel: no auto-advance to fight (WCAG 2.2.2), no focus stolen
|
|
9
|
+
* from an off-screen slide, and it still works before hydration.
|
|
10
|
+
*
|
|
11
|
+
* Every tile carries a decorative index badge (`aria-hidden`, a CSS counter)
|
|
12
|
+
* — a numbered contact sheet rather than a plain image wall, and never a
|
|
13
|
+
* second name for the picture a screen reader would announce.
|
|
14
|
+
*
|
|
15
|
+
* A focusable scrollable region needs an accessible name, or a screen reader
|
|
16
|
+
* announces an unlabelled tab stop; that is WCAG 2.4.3 and 4.1.2 both.
|
|
17
|
+
*/
|
|
18
|
+
export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
|
|
19
|
+
const items = h(
|
|
20
|
+
'ul',
|
|
21
|
+
{ class: 'cg-gallery__items' },
|
|
22
|
+
block.items.map((item) =>
|
|
23
|
+
h(
|
|
24
|
+
'li',
|
|
25
|
+
{ class: 'cg-gallery__item' },
|
|
26
|
+
h('span', { class: 'cg-gallery__index', 'aria-hidden': 'true' }),
|
|
27
|
+
image(ctx, item.media, {
|
|
28
|
+
className: 'cg-gallery__image',
|
|
29
|
+
sizes: '(min-width: 48rem) 22rem, 60vw',
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return h(
|
|
36
|
+
'section',
|
|
37
|
+
{
|
|
38
|
+
class: 'cg-block cg-gallery',
|
|
39
|
+
'data-block': 'gallery',
|
|
40
|
+
'data-layout': block.layout,
|
|
41
|
+
},
|
|
42
|
+
block.layout === 'carousel'
|
|
43
|
+
? h(
|
|
44
|
+
'div',
|
|
45
|
+
{
|
|
46
|
+
class: 'cg-gallery__viewport',
|
|
47
|
+
role: 'region',
|
|
48
|
+
'aria-label': ctx.t('gallery.carousel'),
|
|
49
|
+
tabindex: '0',
|
|
50
|
+
},
|
|
51
|
+
items,
|
|
52
|
+
)
|
|
53
|
+
: items,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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 declares `headingLevel: 'h1'`
|
|
14
|
+
* for it, and `renderPage` relies on that to avoid emitting a second one.
|
|
15
|
+
*
|
|
16
|
+
* Structurally distinct from a centred banner: the eyebrow becomes a running
|
|
17
|
+
* index mark set in the mono register (`— eyebrow`), the title is a display
|
|
18
|
+
* headline that overflows its own column on wide screens (`blocks.css` gives
|
|
19
|
+
* it a fluid `clamp()`), and the media is offset into the title's own
|
|
20
|
+
* negative margin rather than sitting in a tidy second column — the overlap
|
|
21
|
+
* is what makes this a poster, not a slide.
|
|
22
|
+
*/
|
|
23
|
+
export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
|
|
24
|
+
const tag = blockHeadingTag('hero') ?? 'h1'
|
|
25
|
+
return h(
|
|
26
|
+
'section',
|
|
27
|
+
{ class: 'cg-block cg-hero', 'data-block': 'hero' },
|
|
28
|
+
h(
|
|
29
|
+
'div',
|
|
30
|
+
{ class: 'cg-hero__frame' },
|
|
31
|
+
block.eyebrow === undefined
|
|
32
|
+
? null
|
|
33
|
+
: h('p', { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
|
|
34
|
+
heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
|
|
35
|
+
block.subtitle === undefined
|
|
36
|
+
? null
|
|
37
|
+
: h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
|
|
38
|
+
actionList(ctx, block.actions, ctx.t('hero.actions')),
|
|
39
|
+
),
|
|
40
|
+
block.media === undefined
|
|
41
|
+
? null
|
|
42
|
+
: h(
|
|
43
|
+
'div',
|
|
44
|
+
{ class: 'cg-hero__media' },
|
|
45
|
+
// The only image on the page that is above the fold by construction,
|
|
46
|
+
// so the only one that must not be lazy: lazy-loading the LCP element
|
|
47
|
+
// is a measured Lighthouse regression, not a theoretical one.
|
|
48
|
+
image(ctx, block.media, {
|
|
49
|
+
className: 'cg-hero__image',
|
|
50
|
+
loading: 'eager',
|
|
51
|
+
sizes: '(min-width: 64rem) 46vw, 100vw',
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lighter-weight than `logos` on purpose (RFC 0001): no per-mark `name`/
|
|
6
|
+
* `url`, so no link, no accessible-name field to write, and no numbered
|
|
7
|
+
* ledger row — this is the quieter "as seen in" strip, marks set edge to
|
|
8
|
+
* edge with no dividers, desaturated exactly like `logos.ts`'s own marks
|
|
9
|
+
* (the theme's one visual language for "a logo not currently in focus").
|
|
10
|
+
* The image's own alt text (set once, in the media library) is what names
|
|
11
|
+
* each mark, same as `logos.ts` falls back to when a link is absent — never
|
|
12
|
+
* invented here, which is why `image` is called with no `altFrom`.
|
|
13
|
+
*/
|
|
14
|
+
function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
|
|
15
|
+
return h(
|
|
16
|
+
'li',
|
|
17
|
+
{ class: 'cg-logostrip__item' },
|
|
18
|
+
image(ctx, item.media, {
|
|
19
|
+
className: 'cg-logostrip__image',
|
|
20
|
+
variant: { fit: 'contain' },
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
|
|
26
|
+
return h(
|
|
27
|
+
'figure',
|
|
28
|
+
{ class: 'cg-block cg-logostrip', 'data-block': 'logoStrip' },
|
|
29
|
+
h(
|
|
30
|
+
'ul',
|
|
31
|
+
{ class: 'cg-logostrip__items' },
|
|
32
|
+
block.logos.map((item) => renderItem(item, ctx)),
|
|
33
|
+
),
|
|
34
|
+
block.caption === undefined
|
|
35
|
+
? null
|
|
36
|
+
: h('figcaption', { class: 'cg-logostrip__caption', 'data-field': 'caption' }, block.caption),
|
|
37
|
+
)
|
|
38
|
+
}
|