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