@cogenta/theme-magazine 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 +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +21 -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 +20 -0
- package/dist/src/render/blocks/accordion.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +5 -0
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
- package/dist/src/render/blocks/collection-list.js +134 -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 +76 -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 +18 -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 +27 -0
- package/dist/src/render/blocks/feature-grid.js.map +1 -0
- package/dist/src/render/blocks/gallery.d.ts +13 -0
- package/dist/src/render/blocks/gallery.d.ts.map +1 -0
- package/dist/src/render/blocks/gallery.js +26 -0
- package/dist/src/render/blocks/gallery.js.map +1 -0
- package/dist/src/render/blocks/hero.d.ts +11 -0
- package/dist/src/render/blocks/hero.d.ts.map +1 -0
- package/dist/src/render/blocks/hero.js +26 -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 +20 -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 +27 -0
- package/dist/src/render/blocks/logos.js.map +1 -0
- package/dist/src/render/blocks/media-figure.d.ts +15 -0
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
- package/dist/src/render/blocks/media-figure.js +32 -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 +32 -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 +16 -0
- package/dist/src/render/blocks/quote.d.ts.map +1 -0
- package/dist/src/render/blocks/quote.js +29 -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 +17 -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 +19 -0
- package/dist/src/render/blocks/stats.js.map +1 -0
- package/dist/src/render/blocks/testimonial.d.ts +19 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +28 -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 +136 -0
- package/dist/src/render/chrome.js.map +1 -0
- package/dist/src/render/render-block.d.ts +21 -0
- package/dist/src/render/render-block.d.ts.map +1 -0
- package/dist/src/render/render-block.js +104 -0
- package/dist/src/render/render-block.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +13 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +35 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/theme.config.d.ts +12 -0
- package/dist/theme.config.d.ts.map +1 -0
- package/dist/theme.config.js +42 -0
- package/dist/theme.config.js.map +1 -0
- package/package.json +57 -0
- package/src/index.ts +20 -0
- package/src/render/blocks/accordion.ts +51 -0
- package/src/render/blocks/collection-list.ts +286 -0
- package/src/render/blocks/cta.ts +33 -0
- package/src/render/blocks/embed.ts +99 -0
- package/src/render/blocks/faq.ts +58 -0
- package/src/render/blocks/feature-grid.ts +61 -0
- package/src/render/blocks/gallery.ts +46 -0
- package/src/render/blocks/hero.ts +54 -0
- package/src/render/blocks/logo-strip.ts +35 -0
- package/src/render/blocks/logos.ts +57 -0
- package/src/render/blocks/media-figure.ts +43 -0
- package/src/render/blocks/pricing-table.ts +76 -0
- package/src/render/blocks/prose.ts +22 -0
- package/src/render/blocks/quote.ts +46 -0
- package/src/render/blocks/stat-counter.ts +39 -0
- package/src/render/blocks/stats.ts +46 -0
- package/src/render/blocks/testimonial.ts +44 -0
- package/src/render/chrome.ts +162 -0
- package/src/render/render-block.ts +137 -0
- package/src/render/term-archive.ts +87 -0
- package/src/styles/archive.css +40 -0
- package/src/styles/base.css +1037 -0
- package/src/styles/blocks.css +1335 -0
- package/src/styles/theme.css +18 -0
- package/src/styles/tokens.css +308 -0
- package/tokens.json +36 -0
|
@@ -0,0 +1,51 @@
|
|
|
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). A reference dossier, not the reader mailbag:
|
|
13
|
+
* shares `faq`'s `<details>`/`<summary>` mechanics — expanding, keyboard
|
|
14
|
+
* operation and the announced expanded state all come from the browser, at
|
|
15
|
+
* zero bytes of JavaScript — but its own markup and class names (`cg-dossier`
|
|
16
|
+
* rather than `cg-mailbag`), a tab-style entry marker in the margin instead
|
|
17
|
+
* of a numeral, so the two blocks can diverge visually later without one
|
|
18
|
+
* depending on the other. "Accordion" and "frequently asked question" are
|
|
19
|
+
* different editorial intents even where the question/answer shape coincides.
|
|
20
|
+
*/
|
|
21
|
+
function renderItem(item: AccordionItem, ctx: RenderContext): HtmlElement {
|
|
22
|
+
return h(
|
|
23
|
+
'li',
|
|
24
|
+
{ class: 'cg-dossier__entry' },
|
|
25
|
+
h(
|
|
26
|
+
'details',
|
|
27
|
+
{ class: 'cg-dossier__details' },
|
|
28
|
+
h('summary', { class: 'cg-dossier__term' }, item.question),
|
|
29
|
+
h('div', { class: 'cg-dossier__definition' }, renderRichText(ctx, item.answer)),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderAccordion(block: AccordionBlock, ctx: RenderContext): HtmlElement {
|
|
35
|
+
return h(
|
|
36
|
+
'section',
|
|
37
|
+
{ class: 'cg-block cg-dossier', 'data-block': 'accordion' },
|
|
38
|
+
block.title === undefined
|
|
39
|
+
? null
|
|
40
|
+
: heading(
|
|
41
|
+
blockHeadingTag('accordion') ?? 'h2',
|
|
42
|
+
{ class: 'cg-dossier__title', 'data-field': 'title' },
|
|
43
|
+
block.title,
|
|
44
|
+
),
|
|
45
|
+
h(
|
|
46
|
+
'ul',
|
|
47
|
+
{ class: 'cg-dossier__entries' },
|
|
48
|
+
block.items.map((item) => renderItem(item, ctx)),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
export { buildCollectionListQuery as query }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A rubric label, read from whichever of the usual "section-like" field
|
|
24
|
+
* names a collection actually declares — the same "never invented, only the
|
|
25
|
+
* usual convention" rule `entryImage`/`entryExcerpt` (`@cogenta/theme-kit`)
|
|
26
|
+
* already follow, extended locally: contract D's `PageEntryMeta` only
|
|
27
|
+
* resolves *taxonomy* classifications into an eyebrow (`renderEntryHeader`),
|
|
28
|
+
* never an arbitrary `select` field, so a magazine whose "section" is a
|
|
29
|
+
* plain field (the `magazine` blueprint's own choice — see `blueprints/
|
|
30
|
+
* magazine.ts`, kept deliberately non-taxonomic so this exact value is
|
|
31
|
+
* readable raw here) can still show it on a card, where the full
|
|
32
|
+
* `ContentEntry` — unlike a rendered page's `PageEntryMeta` — is available.
|
|
33
|
+
*/
|
|
34
|
+
const SECTION_FIELDS = ['section', 'category', 'topic', 'department'] as const
|
|
35
|
+
|
|
36
|
+
function entrySection(entry: ContentEntry): string | undefined {
|
|
37
|
+
for (const field of SECTION_FIELDS) {
|
|
38
|
+
const value = entry[field]
|
|
39
|
+
if (typeof value === 'string' && value.trim() !== '') return value
|
|
40
|
+
}
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatDate(iso: string, ctx: RenderContext, style: 'long' | 'medium'): string {
|
|
45
|
+
try {
|
|
46
|
+
return new Intl.DateTimeFormat(ctx.locale, { dateStyle: style }).format(new Date(iso))
|
|
47
|
+
} catch {
|
|
48
|
+
return iso
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The "une": a large 16:9 cover, the section eyebrow (falling back to the
|
|
54
|
+
* generic "Featured" kicker when the entry carries none), a big headline and
|
|
55
|
+
* the full excerpt — the front-page treatment `layout: 'grid'` gives its
|
|
56
|
+
* first entry, per the L25 pro-pass brief.
|
|
57
|
+
*/
|
|
58
|
+
function renderLead(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
59
|
+
const date = entryDate(entry)
|
|
60
|
+
const excerpt = entryExcerpt(entry)
|
|
61
|
+
const section = entrySection(entry)
|
|
62
|
+
const cover = entryImage(entry, ctx, { width: 1200, height: 675, fit: 'cover' })
|
|
63
|
+
return h(
|
|
64
|
+
'article',
|
|
65
|
+
{ class: 'cg-issue__lead' },
|
|
66
|
+
cover === undefined
|
|
67
|
+
? null
|
|
68
|
+
: h(
|
|
69
|
+
'a',
|
|
70
|
+
{ class: 'cg-issue__lead-cover-link', href: entryHref(entry, ctx), tabindex: -1 },
|
|
71
|
+
renderImageSource(cover, {
|
|
72
|
+
className: 'cg-issue__lead-cover',
|
|
73
|
+
loading: 'eager',
|
|
74
|
+
sizes: '(min-width: 64rem) 76rem, 100vw',
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
h('div', { class: 'cg-issue__lead-body' }, [
|
|
78
|
+
h('p', { class: 'cg-issue__kicker' }, section ?? ctx.t('collection.featured')),
|
|
79
|
+
heading(
|
|
80
|
+
tag,
|
|
81
|
+
{ class: 'cg-issue__lead-title' },
|
|
82
|
+
h('a', { class: 'cg-issue__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
|
|
83
|
+
),
|
|
84
|
+
excerpt === undefined ? null : h('p', { class: 'cg-issue__lead-excerpt' }, excerpt),
|
|
85
|
+
date === undefined
|
|
86
|
+
? null
|
|
87
|
+
: h(
|
|
88
|
+
'time',
|
|
89
|
+
{ class: 'cg-issue__lead-date', datetime: date },
|
|
90
|
+
formatDate(date, ctx, 'long'),
|
|
91
|
+
),
|
|
92
|
+
]),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A card in the "rest" 3-column grid: cover, section eyebrow, title, date — no excerpt (the lead alone carries one). */
|
|
97
|
+
function renderCard(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
98
|
+
const date = entryDate(entry)
|
|
99
|
+
const section = entrySection(entry)
|
|
100
|
+
const cover = entryImage(entry, ctx, { width: 480, height: 320, fit: 'cover' })
|
|
101
|
+
return h(
|
|
102
|
+
'li',
|
|
103
|
+
{ class: 'cg-issue__card-item' },
|
|
104
|
+
h(
|
|
105
|
+
'article',
|
|
106
|
+
{ class: 'cg-issue__card' },
|
|
107
|
+
cover === undefined
|
|
108
|
+
? null
|
|
109
|
+
: h(
|
|
110
|
+
'a',
|
|
111
|
+
{ class: 'cg-issue__card-cover-link', href: entryHref(entry, ctx), tabindex: -1 },
|
|
112
|
+
renderImageSource(cover, {
|
|
113
|
+
className: 'cg-issue__card-cover',
|
|
114
|
+
sizes: '(min-width: 64rem) 24rem, (min-width: 48rem) 45vw, 100vw',
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
section === undefined ? null : h('p', { class: 'cg-issue__card-eyebrow' }, section),
|
|
118
|
+
heading(
|
|
119
|
+
tag,
|
|
120
|
+
{ class: 'cg-issue__card-title' },
|
|
121
|
+
h('a', { class: 'cg-issue__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
|
|
122
|
+
),
|
|
123
|
+
date === undefined
|
|
124
|
+
? null
|
|
125
|
+
: h(
|
|
126
|
+
'time',
|
|
127
|
+
{ class: 'cg-issue__card-date', datetime: date },
|
|
128
|
+
formatDate(date, ctx, 'medium'),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A rail row for `layout: 'list'` — a small square thumbnail (when the entry
|
|
136
|
+
* has one) beside the title and date, the "rubric rail" treatment; a plain
|
|
137
|
+
* numbered row (this theme's original front-page-index look) when the entry
|
|
138
|
+
* carries no image, so a collection with no cover field still reads as a
|
|
139
|
+
* considered contents strip rather than a broken thumbnail.
|
|
140
|
+
*/
|
|
141
|
+
function renderRow(
|
|
142
|
+
entry: ContentEntry,
|
|
143
|
+
ctx: RenderContext,
|
|
144
|
+
tag: HeadingTag,
|
|
145
|
+
index: number,
|
|
146
|
+
): HtmlElement {
|
|
147
|
+
const date = entryDate(entry)
|
|
148
|
+
const cover = entryImage(entry, ctx, { width: 112, height: 112, fit: 'cover' })
|
|
149
|
+
return h(
|
|
150
|
+
'li',
|
|
151
|
+
{ class: 'cg-issue__row' },
|
|
152
|
+
cover === undefined
|
|
153
|
+
? h(
|
|
154
|
+
'span',
|
|
155
|
+
{ class: 'cg-issue__row-number', 'aria-hidden': 'true' },
|
|
156
|
+
String(index).padStart(2, '0'),
|
|
157
|
+
)
|
|
158
|
+
: h(
|
|
159
|
+
'a',
|
|
160
|
+
{ class: 'cg-issue__row-thumb-link', href: entryHref(entry, ctx), tabindex: -1 },
|
|
161
|
+
renderImageSource(cover, { className: 'cg-issue__row-thumb' }),
|
|
162
|
+
),
|
|
163
|
+
h(
|
|
164
|
+
'div',
|
|
165
|
+
{ class: 'cg-issue__row-body' },
|
|
166
|
+
heading(
|
|
167
|
+
tag,
|
|
168
|
+
{ class: 'cg-issue__row-title' },
|
|
169
|
+
h('a', { class: 'cg-issue__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
|
|
170
|
+
),
|
|
171
|
+
date === undefined
|
|
172
|
+
? null
|
|
173
|
+
: h(
|
|
174
|
+
'time',
|
|
175
|
+
{ class: 'cg-issue__row-date', datetime: date },
|
|
176
|
+
formatDate(date, ctx, 'medium'),
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A uniform frame for the horizontal-scroll `carousel` layout — a cover, the section eyebrow, title, date, excerpt. */
|
|
183
|
+
function renderFrame(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
184
|
+
const date = entryDate(entry)
|
|
185
|
+
const excerpt = entryExcerpt(entry)
|
|
186
|
+
const section = entrySection(entry)
|
|
187
|
+
const cover = entryImage(entry, ctx, { width: 480, height: 320, fit: 'cover' })
|
|
188
|
+
return h(
|
|
189
|
+
'li',
|
|
190
|
+
{ class: 'cg-issue__frame' },
|
|
191
|
+
cover === undefined
|
|
192
|
+
? null
|
|
193
|
+
: h(
|
|
194
|
+
'a',
|
|
195
|
+
{ class: 'cg-issue__frame-cover-link', href: entryHref(entry, ctx), tabindex: -1 },
|
|
196
|
+
renderImageSource(cover, { className: 'cg-issue__frame-cover' }),
|
|
197
|
+
),
|
|
198
|
+
section === undefined ? null : h('p', { class: 'cg-issue__card-eyebrow' }, section),
|
|
199
|
+
heading(
|
|
200
|
+
tag,
|
|
201
|
+
{ class: 'cg-issue__frame-title' },
|
|
202
|
+
h('a', { class: 'cg-issue__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
|
|
203
|
+
),
|
|
204
|
+
date === undefined
|
|
205
|
+
? null
|
|
206
|
+
: h(
|
|
207
|
+
'time',
|
|
208
|
+
{ class: 'cg-issue__frame-date', datetime: date },
|
|
209
|
+
formatDate(date, ctx, 'medium'),
|
|
210
|
+
),
|
|
211
|
+
excerpt === undefined ? null : h('p', { class: 'cg-issue__frame-excerpt' }, excerpt),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function renderGrid(entries: readonly ContentEntry[], ctx: RenderContext, tag: HeadingTag) {
|
|
216
|
+
const [first, ...rest] = entries
|
|
217
|
+
if (first === undefined) return null
|
|
218
|
+
return h(
|
|
219
|
+
'div',
|
|
220
|
+
{ class: 'cg-issue__spread' },
|
|
221
|
+
renderLead(first, ctx, tag),
|
|
222
|
+
rest.length === 0
|
|
223
|
+
? null
|
|
224
|
+
: h(
|
|
225
|
+
'ul',
|
|
226
|
+
{ class: 'cg-issue__cards' },
|
|
227
|
+
rest.map((entry) => renderCard(entry, ctx, tag)),
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderList(entries: readonly ContentEntry[], ctx: RenderContext, tag: HeadingTag) {
|
|
233
|
+
return h(
|
|
234
|
+
'ul',
|
|
235
|
+
{ class: 'cg-issue__rows' },
|
|
236
|
+
entries.map((entry, index) => renderRow(entry, ctx, tag, index + 1)),
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function renderCollectionList(
|
|
241
|
+
block: CollectionListBlock,
|
|
242
|
+
ctx: RenderContext,
|
|
243
|
+
entries: readonly ContentEntry[],
|
|
244
|
+
): HtmlElement {
|
|
245
|
+
const hasTitle = block.title !== undefined
|
|
246
|
+
const entryTag = nestedHeadingTag('collectionList', hasTitle)
|
|
247
|
+
|
|
248
|
+
const body =
|
|
249
|
+
entries.length === 0
|
|
250
|
+
? h('p', { class: 'cg-issue__empty' }, ctx.t('collection.empty'))
|
|
251
|
+
: block.layout === 'grid'
|
|
252
|
+
? renderGrid(entries, ctx, entryTag)
|
|
253
|
+
: block.layout === 'carousel'
|
|
254
|
+
? h(
|
|
255
|
+
'div',
|
|
256
|
+
{
|
|
257
|
+
class: 'cg-issue__viewport',
|
|
258
|
+
role: 'region',
|
|
259
|
+
'aria-label': block.title ?? ctx.t('collection.carousel'),
|
|
260
|
+
tabindex: '0',
|
|
261
|
+
},
|
|
262
|
+
h(
|
|
263
|
+
'ul',
|
|
264
|
+
{ class: 'cg-issue__frames' },
|
|
265
|
+
entries.map((entry) => renderFrame(entry, ctx, entryTag)),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
: renderList(entries, ctx, entryTag)
|
|
269
|
+
|
|
270
|
+
return h(
|
|
271
|
+
'section',
|
|
272
|
+
{
|
|
273
|
+
class: 'cg-block cg-issue',
|
|
274
|
+
'data-block': 'collectionList',
|
|
275
|
+
'data-layout': block.layout,
|
|
276
|
+
},
|
|
277
|
+
hasTitle
|
|
278
|
+
? heading(
|
|
279
|
+
blockHeadingTag('collectionList') ?? 'h2',
|
|
280
|
+
{ class: 'cg-issue__title', 'data-field': 'title' },
|
|
281
|
+
block.title ?? '',
|
|
282
|
+
)
|
|
283
|
+
: null,
|
|
284
|
+
body,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 subscription page a print magazine always carries: a panel framed by
|
|
13
|
+
* a heavy top-and-bottom rule rather than a rounded, shadowed card — the
|
|
14
|
+
* theme's one deliberately "loud" moment, reserved for the block whose whole
|
|
15
|
+
* job is to ask for something.
|
|
16
|
+
*/
|
|
17
|
+
export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
|
|
18
|
+
return h(
|
|
19
|
+
'section',
|
|
20
|
+
{ class: 'cg-block cg-subscribe', 'data-block': 'cta' },
|
|
21
|
+
heading(
|
|
22
|
+
blockHeadingTag('cta') ?? 'h2',
|
|
23
|
+
{ class: 'cg-subscribe__title', 'data-field': 'title' },
|
|
24
|
+
block.title,
|
|
25
|
+
),
|
|
26
|
+
block.text === undefined
|
|
27
|
+
? null
|
|
28
|
+
: h('p', { class: 'cg-subscribe__text', 'data-field': 'text' }, block.text),
|
|
29
|
+
// `actions` is required and non-empty for a `cta`, so this always renders;
|
|
30
|
+
// the null branch stays reachable only through invalid stored data.
|
|
31
|
+
actionList(ctx, block.actions, block.title),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 — the
|
|
6
|
+
* same rule the reference theme enforces, reused verbatim: an iframe (or a
|
|
7
|
+
* thumbnail fetch) already hands the provider an IP address and a cookie,
|
|
8
|
+
* which is exactly the transfer consent is supposed to gate. No consent
|
|
9
|
+
* *button* ships either, because this theme ships no JavaScript to wire one
|
|
10
|
+
* to: granting consent is a site-wide decision for a future consent layer,
|
|
11
|
+
* not a block's.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
|
|
15
|
+
const url = URL.parse(rawUrl)
|
|
16
|
+
if (url === null) return null
|
|
17
|
+
const segments = url.pathname.split('/').filter((segment) => segment !== '')
|
|
18
|
+
|
|
19
|
+
switch (provider) {
|
|
20
|
+
case 'youtube': {
|
|
21
|
+
const id = url.hostname.endsWith('youtu.be')
|
|
22
|
+
? segments[0]
|
|
23
|
+
: (url.searchParams.get('v') ?? segments[1])
|
|
24
|
+
return id === undefined || id === null ? null : `https://www.youtube-nocookie.com/embed/${id}`
|
|
25
|
+
}
|
|
26
|
+
case 'vimeo': {
|
|
27
|
+
const id = segments.find((segment) => /^\d+$/.test(segment))
|
|
28
|
+
return id === undefined ? null : `https://player.vimeo.com/video/${id}`
|
|
29
|
+
}
|
|
30
|
+
case 'dailymotion': {
|
|
31
|
+
const id = url.hostname.endsWith('dai.ly') ? segments[0] : segments[segments.length - 1]
|
|
32
|
+
return id === undefined ? null : `https://geo.dailymotion.com/player.html?video=${id}`
|
|
33
|
+
}
|
|
34
|
+
case 'spotify': {
|
|
35
|
+
const [kind, id] = segments
|
|
36
|
+
return kind === undefined || id === undefined
|
|
37
|
+
? null
|
|
38
|
+
: `https://open.spotify.com/embed/${kind}/${id}`
|
|
39
|
+
}
|
|
40
|
+
case 'soundcloud':
|
|
41
|
+
return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
|
|
42
|
+
default:
|
|
43
|
+
// bluesky, mastodon, other: a post embed is a script tag this theme
|
|
44
|
+
// does not load. The sidebar note card is the rendering.
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A boxed sidebar note — dashed rule, small caps label — the editorial way of saying "not loaded here". */
|
|
50
|
+
function noteCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
|
|
51
|
+
return h(
|
|
52
|
+
'div',
|
|
53
|
+
{ class: 'cg-sidenote__box' },
|
|
54
|
+
h('p', { class: 'cg-sidenote__label' }, ctx.t('embed.label')),
|
|
55
|
+
h('p', { class: 'cg-sidenote__notice' }, reason),
|
|
56
|
+
h(
|
|
57
|
+
'a',
|
|
58
|
+
{
|
|
59
|
+
class: 'cg-sidenote__link',
|
|
60
|
+
href: ctx.link(block.url),
|
|
61
|
+
rel: 'noopener noreferrer nofollow',
|
|
62
|
+
},
|
|
63
|
+
ctx.t('embed.open', { provider: block.provider }),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
|
|
69
|
+
const source = block.consentRequired ? null : frameSource(block.provider, block.url)
|
|
70
|
+
const ratio = aspectRatio(block.ratio) ?? '16 / 9'
|
|
71
|
+
|
|
72
|
+
return h(
|
|
73
|
+
'div',
|
|
74
|
+
{
|
|
75
|
+
class: 'cg-block cg-sidenote',
|
|
76
|
+
'data-block': 'embed',
|
|
77
|
+
'data-provider': block.provider,
|
|
78
|
+
'data-consent': block.consentRequired ? 'required' : 'not-required',
|
|
79
|
+
style: `--cg-ratio:${ratio}`,
|
|
80
|
+
},
|
|
81
|
+
source === null
|
|
82
|
+
? noteCard(
|
|
83
|
+
block,
|
|
84
|
+
ctx,
|
|
85
|
+
block.consentRequired
|
|
86
|
+
? ctx.t('embed.consentRequired', { provider: block.provider })
|
|
87
|
+
: ctx.t('embed.unsupported', { provider: block.provider }),
|
|
88
|
+
)
|
|
89
|
+
: h('iframe', {
|
|
90
|
+
class: 'cg-sidenote__frame',
|
|
91
|
+
src: source,
|
|
92
|
+
title: ctx.t('embed.title', { provider: block.provider }),
|
|
93
|
+
loading: 'lazy',
|
|
94
|
+
referrerpolicy: 'strict-origin-when-cross-origin',
|
|
95
|
+
allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
|
|
96
|
+
allowfullscreen: true,
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
* A reader-mailbag column: numbered questions set in the display serif, the
|
|
13
|
+
* answer running underneath once opened. `<details>`/`<summary>` still does
|
|
14
|
+
* all the work — expand/collapse, keyboard operation and the announced
|
|
15
|
+
* expanded state all come from the browser at zero bytes of JavaScript,
|
|
16
|
+
* exactly as in the reference theme; only the typography and the numbering
|
|
17
|
+
* are this theme's own.
|
|
18
|
+
*/
|
|
19
|
+
function renderItem(item: FaqItem, ctx: RenderContext, index: number): HtmlElement {
|
|
20
|
+
return h(
|
|
21
|
+
'li',
|
|
22
|
+
{ class: 'cg-mailbag__item' },
|
|
23
|
+
h(
|
|
24
|
+
'details',
|
|
25
|
+
{ class: 'cg-mailbag__details' },
|
|
26
|
+
h(
|
|
27
|
+
'summary',
|
|
28
|
+
{ class: 'cg-mailbag__question' },
|
|
29
|
+
h(
|
|
30
|
+
'span',
|
|
31
|
+
{ class: 'cg-mailbag__number', 'aria-hidden': 'true' },
|
|
32
|
+
String(index + 1).padStart(2, '0'),
|
|
33
|
+
),
|
|
34
|
+
h('span', { class: 'cg-mailbag__question-text' }, item.question),
|
|
35
|
+
),
|
|
36
|
+
h('div', { class: 'cg-mailbag__answer' }, renderRichText(ctx, item.answer)),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
|
|
42
|
+
return h(
|
|
43
|
+
'section',
|
|
44
|
+
{ class: 'cg-block cg-mailbag', 'data-block': 'faq' },
|
|
45
|
+
block.title === undefined
|
|
46
|
+
? null
|
|
47
|
+
: heading(
|
|
48
|
+
blockHeadingTag('faq') ?? 'h2',
|
|
49
|
+
{ class: 'cg-mailbag__title', 'data-field': 'title' },
|
|
50
|
+
block.title,
|
|
51
|
+
),
|
|
52
|
+
h(
|
|
53
|
+
'ul',
|
|
54
|
+
{ class: 'cg-mailbag__items' },
|
|
55
|
+
block.items.map((item, index) => renderItem(item, ctx, index)),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -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
|
+
} from '@cogenta/theme-kit'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A contents index, not a card grid: a bordered panel, a real newspaper
|
|
15
|
+
* `column-rule` between the columns it falls into at width, and each entry
|
|
16
|
+
* numbered by a CSS counter rather than an icon chip — this vocabulary's
|
|
17
|
+
* `icon` names a symbol no icon set this theme ships can resolve into
|
|
18
|
+
* markup, so a numeral the browser generates for free reads as considered
|
|
19
|
+
* rather than as a placeholder.
|
|
20
|
+
*
|
|
21
|
+
* The item's own title carries the link, exactly as in the reference theme:
|
|
22
|
+
* a bare "read more" repeated across a grid fails WCAG 2.4.4, and the block
|
|
23
|
+
* has no separate label field to fix that with.
|
|
24
|
+
*/
|
|
25
|
+
function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
26
|
+
const title =
|
|
27
|
+
item.link === undefined
|
|
28
|
+
? heading(tag, { class: 'cg-index__title' }, item.title)
|
|
29
|
+
: heading(
|
|
30
|
+
tag,
|
|
31
|
+
{ class: 'cg-index__title' },
|
|
32
|
+
h('a', { class: 'cg-index__link', href: href(ctx, item.link) }, item.title),
|
|
33
|
+
)
|
|
34
|
+
return h(
|
|
35
|
+
'li',
|
|
36
|
+
{ class: 'cg-index__item', 'data-icon': item.icon },
|
|
37
|
+
title,
|
|
38
|
+
item.text === undefined ? null : h('p', { class: 'cg-index__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-block cg-index', 'data-block': 'featureGrid' },
|
|
48
|
+
hasTitle
|
|
49
|
+
? heading(
|
|
50
|
+
blockHeadingTag('featureGrid') ?? 'h2',
|
|
51
|
+
{ class: 'cg-index__heading', 'data-field': 'title' },
|
|
52
|
+
block.title ?? '',
|
|
53
|
+
)
|
|
54
|
+
: null,
|
|
55
|
+
h(
|
|
56
|
+
'ol',
|
|
57
|
+
{ class: 'cg-index__items' },
|
|
58
|
+
block.items.map((item) => renderItem(item, ctx, itemTag)),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { GalleryBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A contact sheet: numbered frames (a CSS counter, drawn in the negative
|
|
6
|
+
* space of the print corner) in a tight grid with hairline gutters, rather
|
|
7
|
+
* than the reference theme's rounded, shadowed tiles.
|
|
8
|
+
*
|
|
9
|
+
* The carousel layout ships **no JavaScript** — a scroll-snapping, focusable,
|
|
10
|
+
* labelled region gives keyboard, touch and trackpad scrolling for free
|
|
11
|
+
* (WCAG 2.4.3/4.1.2), with none of a scripted carousel's failure modes.
|
|
12
|
+
*/
|
|
13
|
+
export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
|
|
14
|
+
const items = h(
|
|
15
|
+
'ul',
|
|
16
|
+
{ class: 'cg-contactsheet__items' },
|
|
17
|
+
block.items.map((item) =>
|
|
18
|
+
h(
|
|
19
|
+
'li',
|
|
20
|
+
{ class: 'cg-contactsheet__item' },
|
|
21
|
+
image(ctx, item.media, { sizes: '(min-width: 45rem) 18rem, 50vw' }),
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return h(
|
|
27
|
+
'section',
|
|
28
|
+
{
|
|
29
|
+
class: 'cg-block cg-contactsheet',
|
|
30
|
+
'data-block': 'gallery',
|
|
31
|
+
'data-layout': block.layout,
|
|
32
|
+
},
|
|
33
|
+
block.layout === 'carousel'
|
|
34
|
+
? h(
|
|
35
|
+
'div',
|
|
36
|
+
{
|
|
37
|
+
class: 'cg-contactsheet__viewport',
|
|
38
|
+
role: 'region',
|
|
39
|
+
'aria-label': ctx.t('gallery.carousel'),
|
|
40
|
+
tabindex: '0',
|
|
41
|
+
},
|
|
42
|
+
items,
|
|
43
|
+
)
|
|
44
|
+
: items,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 cover story. `hero` carries the page's own `h1` (contract B's
|
|
14
|
+
* `headingLevel: 'h1'`), so this is the one place the masthead's front page
|
|
15
|
+
* lives: an eyebrow set as a kicker, a big serif headline, a dek, and the
|
|
16
|
+
* lead image on the other side of a hairline column rule — the two-column
|
|
17
|
+
* "story well" a print front page opens on, rather than a centred banner.
|
|
18
|
+
*/
|
|
19
|
+
export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
|
|
20
|
+
const tag = blockHeadingTag('hero') ?? 'h1'
|
|
21
|
+
return h(
|
|
22
|
+
'section',
|
|
23
|
+
{ class: 'cg-block cg-hero', 'data-block': 'hero' },
|
|
24
|
+
h(
|
|
25
|
+
'div',
|
|
26
|
+
{ class: 'cg-hero__grid' },
|
|
27
|
+
h(
|
|
28
|
+
'div',
|
|
29
|
+
{ class: 'cg-hero__copy' },
|
|
30
|
+
block.eyebrow === undefined
|
|
31
|
+
? null
|
|
32
|
+
: h('p', { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
|
|
33
|
+
heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
|
|
34
|
+
block.subtitle === undefined
|
|
35
|
+
? null
|
|
36
|
+
: h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
|
|
37
|
+
actionList(ctx, block.actions, ctx.t('hero.actions')),
|
|
38
|
+
),
|
|
39
|
+
block.media === undefined
|
|
40
|
+
? null
|
|
41
|
+
: h(
|
|
42
|
+
'div',
|
|
43
|
+
{ class: 'cg-hero__media' },
|
|
44
|
+
// The only image above the fold, so the only one loaded eagerly —
|
|
45
|
+
// lazy-loading it is a measured LCP regression, not a theory.
|
|
46
|
+
image(ctx, block.media, {
|
|
47
|
+
className: 'cg-hero__image',
|
|
48
|
+
loading: 'eager',
|
|
49
|
+
sizes: '(min-width: 64rem) 56vw, 100vw',
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
}
|