@cogenta/theme-association 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +16 -0
- package/dist/src/render/blocks/accordion.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +12 -0
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
- package/dist/src/render/blocks/collection-list.js +64 -0
- package/dist/src/render/blocks/collection-list.js.map +1 -0
- package/dist/src/render/blocks/cta.d.ts +9 -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 +21 -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 +14 -0
- package/dist/src/render/blocks/gallery.d.ts.map +1 -0
- package/dist/src/render/blocks/gallery.js +27 -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 +16 -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 +22 -0
- package/dist/src/render/blocks/logos.js.map +1 -0
- package/dist/src/render/blocks/media-figure.d.ts +12 -0
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
- package/dist/src/render/blocks/media-figure.js +27 -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 +9 -0
- package/dist/src/render/blocks/prose.d.ts.map +1 -0
- package/dist/src/render/blocks/prose.js +10 -0
- package/dist/src/render/blocks/prose.js.map +1 -0
- package/dist/src/render/blocks/quote.d.ts +13 -0
- package/dist/src/render/blocks/quote.d.ts.map +1 -0
- package/dist/src/render/blocks/quote.js +26 -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 +14 -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 +18 -0
- package/dist/src/render/blocks/stats.js.map +1 -0
- package/dist/src/render/blocks/testimonial.d.ts +11 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +20 -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 +106 -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 +3 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +42 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/theme.config.d.ts +15 -0
- package/dist/theme.config.d.ts.map +1 -0
- package/dist/theme.config.js +45 -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 +47 -0
- package/src/render/blocks/collection-list.ts +144 -0
- package/src/render/blocks/cta.ts +37 -0
- package/src/render/blocks/embed.ts +98 -0
- package/src/render/blocks/faq.ts +52 -0
- package/src/render/blocks/feature-grid.ts +67 -0
- package/src/render/blocks/gallery.ts +47 -0
- package/src/render/blocks/hero.ts +60 -0
- package/src/render/blocks/logo-strip.ts +31 -0
- package/src/render/blocks/logos.ts +52 -0
- package/src/render/blocks/media-figure.ts +38 -0
- package/src/render/blocks/pricing-table.ts +73 -0
- package/src/render/blocks/prose.ts +15 -0
- package/src/render/blocks/quote.ts +39 -0
- package/src/render/blocks/stat-counter.ts +36 -0
- package/src/render/blocks/stats.ts +45 -0
- package/src/render/blocks/testimonial.ts +36 -0
- package/src/render/chrome.ts +134 -0
- package/src/render/render-block.ts +140 -0
- package/src/render/term-archive.ts +92 -0
- package/src/styles/archive.css +129 -0
- package/src/styles/base.css +842 -0
- package/src/styles/blocks.css +1002 -0
- package/src/styles/theme.css +31 -0
- package/src/styles/tokens.css +332 -0
- package/tokens.json +36 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PricingTableBlock, PricingTier } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
actionLink,
|
|
4
|
+
blockHeadingTag,
|
|
5
|
+
type HeadingTag,
|
|
6
|
+
type HtmlElement,
|
|
7
|
+
h,
|
|
8
|
+
heading,
|
|
9
|
+
nestedHeadingTag,
|
|
10
|
+
type RenderContext,
|
|
11
|
+
} from '@cogenta/theme-kit'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `blocks@2.0` (RFC 0001). Rarely a nonprofit's own price list, but not never
|
|
15
|
+
* (a membership fee, a course fee) — kept in the shared, rounded-card
|
|
16
|
+
* register the rest of this theme uses. `highlighted` becomes
|
|
17
|
+
* `data-highlighted` for the skin to style, and `aria-current` so it is
|
|
18
|
+
* announced, not only shown.
|
|
19
|
+
*/
|
|
20
|
+
function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
21
|
+
return h(
|
|
22
|
+
'li',
|
|
23
|
+
{
|
|
24
|
+
class: 'cg-pricing__tier',
|
|
25
|
+
'data-highlighted': tier.highlighted === true ? 'true' : undefined,
|
|
26
|
+
'aria-current': tier.highlighted === true ? 'true' : undefined,
|
|
27
|
+
},
|
|
28
|
+
heading(tag, { class: 'cg-pricing__name' }, tier.name),
|
|
29
|
+
h(
|
|
30
|
+
'p',
|
|
31
|
+
{ class: 'cg-pricing__price' },
|
|
32
|
+
h('span', { class: 'cg-pricing__amount' }, tier.price),
|
|
33
|
+
tier.interval === undefined
|
|
34
|
+
? null
|
|
35
|
+
: h('span', { class: 'cg-pricing__interval' }, tier.interval),
|
|
36
|
+
),
|
|
37
|
+
tier.features.length === 0
|
|
38
|
+
? null
|
|
39
|
+
: h(
|
|
40
|
+
'ul',
|
|
41
|
+
{ class: 'cg-pricing__features' },
|
|
42
|
+
tier.features.map((feature) => h('li', {}, feature)),
|
|
43
|
+
),
|
|
44
|
+
tier.action === undefined
|
|
45
|
+
? null
|
|
46
|
+
: h(
|
|
47
|
+
'div',
|
|
48
|
+
{ class: 'cg-pricing__action' },
|
|
49
|
+
actionLink(ctx, { ...tier.action, emphasis: tier.action.emphasis ?? 'primary' }),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
|
|
55
|
+
const hasTitle = block.title !== undefined
|
|
56
|
+
const tierTag = nestedHeadingTag('pricingTable', hasTitle)
|
|
57
|
+
return h(
|
|
58
|
+
'section',
|
|
59
|
+
{ class: 'cg-block cg-pricing', 'data-block': 'pricingTable' },
|
|
60
|
+
hasTitle
|
|
61
|
+
? heading(
|
|
62
|
+
blockHeadingTag('pricingTable') ?? 'h2',
|
|
63
|
+
{ class: 'cg-pricing__title', 'data-field': 'title' },
|
|
64
|
+
block.title ?? '',
|
|
65
|
+
)
|
|
66
|
+
: null,
|
|
67
|
+
h(
|
|
68
|
+
'ul',
|
|
69
|
+
{ class: 'cg-pricing__tiers' },
|
|
70
|
+
block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ProseBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, type RenderContext, renderRichText } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `prose` declares `headingLevel: 'none'`: it contributes no heading of its
|
|
6
|
+
* own. Whatever headings appear come from the rich text document, whose
|
|
7
|
+
* vocabulary starts at `h2`.
|
|
8
|
+
*/
|
|
9
|
+
export function renderProse(block: ProseBlock, ctx: RenderContext): HtmlElement {
|
|
10
|
+
return h(
|
|
11
|
+
'div',
|
|
12
|
+
{ class: 'cg-block cg-prose', 'data-block': 'prose' },
|
|
13
|
+
renderRichText(ctx, block.body),
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { QuoteBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `<figure><blockquote>…</blockquote><figcaption>` is the attribution
|
|
6
|
+
* pattern the HTML spec prescribes: putting the author inside the
|
|
7
|
+
* `<blockquote>` would claim the author's name is part of what was said.
|
|
8
|
+
*
|
|
9
|
+
* The avatar is decorative here — the name is right beside it in text — so
|
|
10
|
+
* its media entity's alt text is expected to be empty. `image` still writes
|
|
11
|
+
* the attribute either way.
|
|
12
|
+
*/
|
|
13
|
+
export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement {
|
|
14
|
+
const hasAttribution =
|
|
15
|
+
block.author !== undefined || block.role !== undefined || block.avatar !== undefined
|
|
16
|
+
return h(
|
|
17
|
+
'figure',
|
|
18
|
+
{ class: 'cg-block cg-quote', 'data-block': 'quote' },
|
|
19
|
+
h('blockquote', { class: 'cg-quote__text' }, h('p', { 'data-field': 'text' }, block.text)),
|
|
20
|
+
hasAttribution
|
|
21
|
+
? h(
|
|
22
|
+
'figcaption',
|
|
23
|
+
{ class: 'cg-quote__attribution' },
|
|
24
|
+
block.avatar === undefined
|
|
25
|
+
? null
|
|
26
|
+
: image(ctx, block.avatar, {
|
|
27
|
+
className: 'cg-quote__avatar',
|
|
28
|
+
variant: { width: 96, height: 96, fit: 'cover' },
|
|
29
|
+
}),
|
|
30
|
+
block.author === undefined
|
|
31
|
+
? null
|
|
32
|
+
: h('span', { class: 'cg-quote__author', 'data-field': 'author' }, block.author),
|
|
33
|
+
block.role === undefined
|
|
34
|
+
? null
|
|
35
|
+
: h('span', { class: 'cg-quote__role', 'data-field': 'role' }, block.role),
|
|
36
|
+
)
|
|
37
|
+
: null,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { StatCounterBlock, StatCounterItem } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
type RenderContext,
|
|
8
|
+
} from '@cogenta/theme-kit'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `blocks@2.0` (RFC 0001). A `<dl>`, exactly like `stats` — narrower on
|
|
12
|
+
* purpose (no `unit`), for one big figure and its label.
|
|
13
|
+
*/
|
|
14
|
+
function renderItem(item: StatCounterItem): HtmlElement {
|
|
15
|
+
return h(
|
|
16
|
+
'div',
|
|
17
|
+
{ class: 'cg-stat-counter' },
|
|
18
|
+
h('dt', { class: 'cg-stat-counter__label' }, item.label),
|
|
19
|
+
h('dd', { class: 'cg-stat-counter__value' }, item.value),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
|
|
24
|
+
return h(
|
|
25
|
+
'section',
|
|
26
|
+
{ class: 'cg-block cg-stat-counters', 'data-block': 'statCounter' },
|
|
27
|
+
block.title === undefined
|
|
28
|
+
? null
|
|
29
|
+
: heading(
|
|
30
|
+
blockHeadingTag('statCounter') ?? 'h2',
|
|
31
|
+
{ class: 'cg-stat-counters__title', 'data-field': 'title' },
|
|
32
|
+
block.title,
|
|
33
|
+
),
|
|
34
|
+
h('dl', { class: 'cg-stat-counters__items' }, block.stats.map(renderItem)),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { StatItem, StatsBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
type RenderContext,
|
|
8
|
+
} from '@cogenta/theme-kit'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The impact band — "12,400 meals served", "380 volunteers" — rendered as a
|
|
12
|
+
* description list so each big figure stays the description of its label
|
|
13
|
+
* (WCAG 1.3.2). Reading order is label then figure, exactly as the markup
|
|
14
|
+
* says; the stylesheet paints the figure first with `column-reverse` (see
|
|
15
|
+
* `blocks.css`'s own comment on `.cg-impact-stat`) — the paint order changes,
|
|
16
|
+
* the DOM order a screen reader follows does not.
|
|
17
|
+
*/
|
|
18
|
+
function renderItem(item: StatItem): HtmlElement {
|
|
19
|
+
return h(
|
|
20
|
+
'div',
|
|
21
|
+
{ class: 'cg-impact-stat' },
|
|
22
|
+
h('dt', { class: 'cg-impact-stat__label' }, item.label),
|
|
23
|
+
h(
|
|
24
|
+
'dd',
|
|
25
|
+
{ class: 'cg-impact-stat__value' },
|
|
26
|
+
item.value,
|
|
27
|
+
item.unit === undefined ? null : h('span', { class: 'cg-impact-stat__unit' }, item.unit),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
|
|
33
|
+
return h(
|
|
34
|
+
'section',
|
|
35
|
+
{ class: 'cg-block cg-impact', 'data-block': 'stats' },
|
|
36
|
+
block.title === undefined
|
|
37
|
+
? null
|
|
38
|
+
: heading(
|
|
39
|
+
blockHeadingTag('stats') ?? 'h2',
|
|
40
|
+
{ class: 'cg-impact__title', 'data-field': 'title' },
|
|
41
|
+
block.title,
|
|
42
|
+
),
|
|
43
|
+
h('dl', { class: 'cg-impact__items' }, block.items.map(renderItem)),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TestimonialBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext, renderRichText } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `blocks@2.0` (RFC 0001). A volunteer's own words — the same
|
|
6
|
+
* `<figure><blockquote>…</blockquote><figcaption>` pattern `quote` uses, but
|
|
7
|
+
* the quote itself is rich text and the attribution is the block's single
|
|
8
|
+
* grouped `attribution` field. The avatar sits in a round frame, echoing the
|
|
9
|
+
* hero's own rounded, friendly treatment.
|
|
10
|
+
*/
|
|
11
|
+
export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
|
|
12
|
+
const { attribution } = block
|
|
13
|
+
return h(
|
|
14
|
+
'figure',
|
|
15
|
+
{ class: 'cg-block cg-testimonial', 'data-block': 'testimonial' },
|
|
16
|
+
h('blockquote', { class: 'cg-testimonial__quote' }, renderRichText(ctx, block.quote)),
|
|
17
|
+
h(
|
|
18
|
+
'figcaption',
|
|
19
|
+
{ class: 'cg-testimonial__attribution' },
|
|
20
|
+
attribution.avatar === undefined
|
|
21
|
+
? null
|
|
22
|
+
: image(ctx, attribution.avatar, {
|
|
23
|
+
className: 'cg-testimonial__avatar',
|
|
24
|
+
variant: { width: 96, height: 96, fit: 'cover' },
|
|
25
|
+
}),
|
|
26
|
+
h(
|
|
27
|
+
'span',
|
|
28
|
+
{ class: 'cg-testimonial__who' },
|
|
29
|
+
h('span', { class: 'cg-testimonial__name' }, attribution.name),
|
|
30
|
+
attribution.role === undefined
|
|
31
|
+
? null
|
|
32
|
+
: h('span', { class: 'cg-testimonial__role' }, attribution.role),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChromeInput,
|
|
3
|
+
type ChromeNavLink,
|
|
4
|
+
type ChromeResult,
|
|
5
|
+
escapeAttribute,
|
|
6
|
+
escapeText,
|
|
7
|
+
renderBrandMark,
|
|
8
|
+
renderSocialLinks,
|
|
9
|
+
renderThemeToggle,
|
|
10
|
+
serialize,
|
|
11
|
+
} from '@cogenta/theme-kit'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* This theme's own header and footer — a warm, community-notice-board read
|
|
15
|
+
* rather than a corporate one: a sticky header with a pill-shaped nav and a
|
|
16
|
+
* rounded, filled action button, and a four-column footer (brand+tagline,
|
|
17
|
+
* navigation, social, a short note) on a tinted band, closing with Cogenta's
|
|
18
|
+
* own credit.
|
|
19
|
+
*
|
|
20
|
+
* The mobile nav is a `<details>`/`<summary>` disclosure — the same
|
|
21
|
+
* zero-JavaScript mechanism `faq`/`accordion` already use — rather than a
|
|
22
|
+
* scripted hamburger: expanding, keyboard operation and the open state
|
|
23
|
+
* announced to assistive technology all come from the browser. The same
|
|
24
|
+
* links are rendered twice (once inside the disclosure, once as a plain
|
|
25
|
+
* inline row) and `src/styles/base.css` shows exactly one of the two at any
|
|
26
|
+
* given width via `display:none` — which also removes the hidden copy from
|
|
27
|
+
* the accessibility tree, so nothing is ever announced twice.
|
|
28
|
+
*
|
|
29
|
+
* `theme@1.4` (L25 D2) fields — `headerAction`, `tagline`, `social`,
|
|
30
|
+
* `footerNote` — are each rendered only when present; a render with none of
|
|
31
|
+
* them set still produces a complete header/footer, exactly the additive
|
|
32
|
+
* guarantee the contract promises.
|
|
33
|
+
*
|
|
34
|
+
* The manual light/dark toggle (`renderThemeToggle`, `@cogenta/theme-kit`,
|
|
35
|
+
* L26) is unconditional — it sits between the desktop nav and the header
|
|
36
|
+
* action, outside the `<details>` mobile disclosure so it stays reachable at
|
|
37
|
+
* every width rather than one tap deep inside the hamburger menu.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
function navItems(links: readonly ChromeNavLink[]): string {
|
|
41
|
+
return links
|
|
42
|
+
.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
|
|
43
|
+
.map((link) => {
|
|
44
|
+
const label = escapeText(link.label)
|
|
45
|
+
const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
|
|
46
|
+
if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
|
|
47
|
+
const href = escapeAttribute(link.href)
|
|
48
|
+
const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
|
|
49
|
+
return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
|
|
50
|
+
})
|
|
51
|
+
.join('')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderNav(links: readonly ChromeNavLink[], className: string, label: string): string {
|
|
55
|
+
const items = navItems(links)
|
|
56
|
+
if (items === '') return ''
|
|
57
|
+
return `<nav class="${className}" aria-label="${escapeAttribute(label)}"><ul class="cg-nav__items">${items}</ul></nav>`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The header's own call-to-action link (`theme@1.4`) — a filled, rounded button, the "Donate"/"Volunteer" treatment. */
|
|
61
|
+
function renderHeaderAction(action: ChromeInput['headerAction']): string {
|
|
62
|
+
if (action === undefined) return ''
|
|
63
|
+
return (
|
|
64
|
+
`<a class="cg-action cg-site-header__action" data-emphasis="primary" ` +
|
|
65
|
+
`href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A plain hamburger glyph — three bars, drawn as `<path>`s so nothing here needs the icon set's closed vocabulary. */
|
|
70
|
+
const MENU_GLYPH =
|
|
71
|
+
'<svg class="cg-nav-toggle__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
|
72
|
+
'<path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>' +
|
|
73
|
+
'</svg>'
|
|
74
|
+
|
|
75
|
+
export function renderChrome(input: ChromeInput): ChromeResult {
|
|
76
|
+
const siteNameText = escapeText(input.site.name)
|
|
77
|
+
const headerNavDesktop = renderNav(input.headerNav, 'cg-nav cg-nav--desktop', 'Primary')
|
|
78
|
+
const headerNavMobile = navItems(input.headerNav)
|
|
79
|
+
const footerNav = renderNav(input.footerNav, 'cg-site-footer__nav', 'Footer')
|
|
80
|
+
const mark = renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ?? siteNameText
|
|
81
|
+
const headerAction = renderHeaderAction(input.headerAction)
|
|
82
|
+
const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
|
|
83
|
+
|
|
84
|
+
const mobileToggle =
|
|
85
|
+
headerNavMobile === ''
|
|
86
|
+
? ''
|
|
87
|
+
: `<details class="cg-nav-toggle">` +
|
|
88
|
+
`<summary class="cg-nav-toggle__button" aria-label="Menu">${MENU_GLYPH}</summary>` +
|
|
89
|
+
`<nav class="cg-nav cg-nav--mobile" aria-label="Primary"><ul class="cg-nav__items">${headerNavMobile}</ul></nav>` +
|
|
90
|
+
`</details>`
|
|
91
|
+
|
|
92
|
+
const header =
|
|
93
|
+
`<header class="cg-site-header"><div class="cg-site-header__inner">` +
|
|
94
|
+
`<a class="cg-site-header__home" href="${escapeAttribute(input.homeHref)}">${mark}</a>` +
|
|
95
|
+
`${headerNavDesktop}` +
|
|
96
|
+
`${themeToggle}` +
|
|
97
|
+
`${headerAction}` +
|
|
98
|
+
`${mobileToggle}` +
|
|
99
|
+
`</div></header>`
|
|
100
|
+
|
|
101
|
+
const tagline =
|
|
102
|
+
input.tagline === undefined
|
|
103
|
+
? ''
|
|
104
|
+
: `<p class="cg-site-footer__tagline">${escapeText(input.tagline)}</p>`
|
|
105
|
+
const social =
|
|
106
|
+
input.social === undefined
|
|
107
|
+
? ''
|
|
108
|
+
: serialize(
|
|
109
|
+
renderSocialLinks(input.social, {
|
|
110
|
+
className: 'cg-site-footer__social',
|
|
111
|
+
itemClassName: 'cg-site-footer__social-item',
|
|
112
|
+
}) ?? { kind: 'text', value: '' },
|
|
113
|
+
)
|
|
114
|
+
const footerNote =
|
|
115
|
+
input.footerNote === undefined
|
|
116
|
+
? ''
|
|
117
|
+
: `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
|
|
118
|
+
|
|
119
|
+
const footer =
|
|
120
|
+
`<footer class="cg-site-footer">` +
|
|
121
|
+
`<div class="cg-site-footer__grid">` +
|
|
122
|
+
`<div class="cg-site-footer__brand">` +
|
|
123
|
+
`<a class="cg-site-footer__brand-link" href="${escapeAttribute(input.homeHref)}">${siteNameText}</a>` +
|
|
124
|
+
`${tagline}` +
|
|
125
|
+
`</div>` +
|
|
126
|
+
`<div class="cg-site-footer__col">${footerNav}</div>` +
|
|
127
|
+
`<div class="cg-site-footer__col">${social}</div>` +
|
|
128
|
+
`<div class="cg-site-footer__col">${footerNote}</div>` +
|
|
129
|
+
`</div>` +
|
|
130
|
+
`<div class="cg-site-footer__bottom"><span>${siteNameText}</span>${input.brandingHtml}</div>` +
|
|
131
|
+
`</footer>`
|
|
132
|
+
|
|
133
|
+
return { header, footer }
|
|
134
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { type BlockRegistry, VOCABULARY_NAMES, type VocabularyBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
type FetchedEntries,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
type PageContent,
|
|
7
|
+
pageHasOwnHeading,
|
|
8
|
+
type RenderContext,
|
|
9
|
+
renderEntryHeader,
|
|
10
|
+
resolveBlockForRender,
|
|
11
|
+
withBlockKey,
|
|
12
|
+
withBlockVariant,
|
|
13
|
+
} from '@cogenta/theme-kit'
|
|
14
|
+
import { renderAccordion } from './blocks/accordion.js'
|
|
15
|
+
import { renderCollectionList } from './blocks/collection-list.js'
|
|
16
|
+
import { renderCta } from './blocks/cta.js'
|
|
17
|
+
import { renderEmbed } from './blocks/embed.js'
|
|
18
|
+
import { renderFaq } from './blocks/faq.js'
|
|
19
|
+
import { renderFeatureGrid } from './blocks/feature-grid.js'
|
|
20
|
+
import { renderGallery } from './blocks/gallery.js'
|
|
21
|
+
import { renderHero } from './blocks/hero.js'
|
|
22
|
+
import { renderLogoStrip } from './blocks/logo-strip.js'
|
|
23
|
+
import { renderLogos } from './blocks/logos.js'
|
|
24
|
+
import { renderMediaFigure } from './blocks/media-figure.js'
|
|
25
|
+
import { renderPricingTable } from './blocks/pricing-table.js'
|
|
26
|
+
import { renderProse } from './blocks/prose.js'
|
|
27
|
+
import { renderQuote } from './blocks/quote.js'
|
|
28
|
+
import { renderStatCounter } from './blocks/stat-counter.js'
|
|
29
|
+
import { renderStats } from './blocks/stats.js'
|
|
30
|
+
import { renderTestimonial } from './blocks/testimonial.js'
|
|
31
|
+
|
|
32
|
+
export type { FetchedEntries, PageContent }
|
|
33
|
+
export { pageHasOwnHeading, renderEntryHeader, withBlockKey }
|
|
34
|
+
|
|
35
|
+
export function renderBlock(
|
|
36
|
+
block: VocabularyBlock,
|
|
37
|
+
ctx: RenderContext,
|
|
38
|
+
entries: FetchedEntries = {},
|
|
39
|
+
registry?: BlockRegistry,
|
|
40
|
+
): HtmlElement | null {
|
|
41
|
+
// `block`'s type says `VocabularyBlock`, but the value crossing this
|
|
42
|
+
// boundary from stored content is not always literally one of the shared
|
|
43
|
+
// vocabulary — resolving here is what turns an unimplemented theme-private
|
|
44
|
+
// block into its declared fallback instead of a silently blank slot.
|
|
45
|
+
const resolved = resolveBlockForRender(block, VOCABULARY_NAMES, registry)
|
|
46
|
+
if (resolved === null) return null
|
|
47
|
+
const known = resolved as unknown as VocabularyBlock
|
|
48
|
+
// `withBlockVariant` (blocks@2.0, RFC 0002) is applied once here, after
|
|
49
|
+
// dispatch, rather than inside each of the seventeen block renderers —
|
|
50
|
+
// `variant` is envelope data every block carries identically.
|
|
51
|
+
return withBlockVariant(renderKnownBlock(known, ctx, entries), known.variant)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderKnownBlock(
|
|
55
|
+
known: VocabularyBlock,
|
|
56
|
+
ctx: RenderContext,
|
|
57
|
+
entries: FetchedEntries,
|
|
58
|
+
): HtmlElement | null {
|
|
59
|
+
switch (known._type) {
|
|
60
|
+
case 'hero':
|
|
61
|
+
return renderHero(known, ctx)
|
|
62
|
+
case 'prose':
|
|
63
|
+
return renderProse(known, ctx)
|
|
64
|
+
case 'mediaFigure':
|
|
65
|
+
return renderMediaFigure(known, ctx)
|
|
66
|
+
case 'featureGrid':
|
|
67
|
+
return renderFeatureGrid(known, ctx)
|
|
68
|
+
case 'cta':
|
|
69
|
+
return renderCta(known, ctx)
|
|
70
|
+
case 'gallery':
|
|
71
|
+
return renderGallery(known, ctx)
|
|
72
|
+
case 'quote':
|
|
73
|
+
return renderQuote(known, ctx)
|
|
74
|
+
case 'faq':
|
|
75
|
+
return renderFaq(known, ctx)
|
|
76
|
+
case 'stats':
|
|
77
|
+
return renderStats(known, ctx)
|
|
78
|
+
case 'logos':
|
|
79
|
+
return renderLogos(known, ctx)
|
|
80
|
+
case 'collectionList':
|
|
81
|
+
return renderCollectionList(known, ctx, entries[known._key] ?? [])
|
|
82
|
+
case 'embed':
|
|
83
|
+
return renderEmbed(known, ctx)
|
|
84
|
+
case 'testimonial':
|
|
85
|
+
return renderTestimonial(known, ctx)
|
|
86
|
+
case 'pricingTable':
|
|
87
|
+
return renderPricingTable(known, ctx)
|
|
88
|
+
case 'accordion':
|
|
89
|
+
return renderAccordion(known, ctx)
|
|
90
|
+
case 'statCounter':
|
|
91
|
+
return renderStatCounter(known, ctx)
|
|
92
|
+
case 'logoStrip':
|
|
93
|
+
return renderLogoStrip(known, ctx)
|
|
94
|
+
default: {
|
|
95
|
+
// Exhaustive over contract B's vocabulary: `known` is `never` here, so
|
|
96
|
+
// a block this package does not implement stops it compiling until it
|
|
97
|
+
// is. Returning null rather than throwing is deliberate — a theme has
|
|
98
|
+
// no access to `@cogenta/core`'s error types (contract D refuses the
|
|
99
|
+
// import), and choosing a fallback block is the render layer's job,
|
|
100
|
+
// not the theme's.
|
|
101
|
+
const unreachable: never = known
|
|
102
|
+
void unreachable
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `<main id="cg-main">` is mandatory: it is the skip-link's target, written
|
|
110
|
+
* once by `@cogenta/cli`'s `theme-render.ts` outside any theme's control.
|
|
111
|
+
*
|
|
112
|
+
* `theme@1.4` (L25 D2): `renderEntryHeader` returns `null` both for a page
|
|
113
|
+
* with no `entry` meta and for one whose blocks already draw their own
|
|
114
|
+
* heading (a `hero`) — the bare `<h1 class="cg-page__title">` fallback below
|
|
115
|
+
* is the right markup in both of those cases and only those, so the page
|
|
116
|
+
* always carries exactly one `<h1>`.
|
|
117
|
+
*
|
|
118
|
+
* `withBlockKey` stamps every rendered block with its contract-B `_key` —
|
|
119
|
+
* required so the visual page builder (L16) can map a clicked element in the
|
|
120
|
+
* rendered iframe back to the block that produced it.
|
|
121
|
+
*/
|
|
122
|
+
export function renderPage(
|
|
123
|
+
page: PageContent,
|
|
124
|
+
ctx: RenderContext,
|
|
125
|
+
entries: FetchedEntries = {},
|
|
126
|
+
registry?: BlockRegistry,
|
|
127
|
+
): HtmlElement {
|
|
128
|
+
const entryHeader = renderEntryHeader(page, ctx)
|
|
129
|
+
return h(
|
|
130
|
+
'main',
|
|
131
|
+
{ class: 'cg-main', id: 'cg-main' },
|
|
132
|
+
entryHeader,
|
|
133
|
+
entryHeader === null && !pageHasOwnHeading(page.blocks)
|
|
134
|
+
? h('h1', { class: 'cg-page__title' }, page.title)
|
|
135
|
+
: null,
|
|
136
|
+
page.blocks.map((block) =>
|
|
137
|
+
withBlockKey(renderBlock(block, ctx, entries, registry), block._key),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type HtmlElement, h, type TermArchiveInput } from '@cogenta/theme-kit'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The taxonomy-term archive (contract D `theme@1.3`) — a card grid, the same
|
|
5
|
+
* register the home page's "Upcoming events" `collectionList` uses: a
|
|
6
|
+
* rounded, shadowed card per entry, with a big day/month badge when the
|
|
7
|
+
* entry carries a publication date, title and summary underneath. There is
|
|
8
|
+
* no `entryImage` here (`TermArchiveEntry` carries no image field), so the
|
|
9
|
+
* card leans on the date badge and warm surface colour for shape instead of
|
|
10
|
+
* a missing photo.
|
|
11
|
+
*/
|
|
12
|
+
function dateBadge(publishedAt: string | null): HtmlElement | null {
|
|
13
|
+
if (publishedAt === null) return null
|
|
14
|
+
const date = new Date(publishedAt)
|
|
15
|
+
if (Number.isNaN(date.getTime())) return null
|
|
16
|
+
return h(
|
|
17
|
+
'time',
|
|
18
|
+
{ class: 'cg-archive-card__date', datetime: publishedAt },
|
|
19
|
+
h('span', { class: 'cg-archive-card__day' }, String(date.getUTCDate())),
|
|
20
|
+
h(
|
|
21
|
+
'span',
|
|
22
|
+
{ class: 'cg-archive-card__month' },
|
|
23
|
+
new Intl.DateTimeFormat('en', { month: 'short' }).format(date),
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderTermArchive(input: TermArchiveInput): HtmlElement {
|
|
29
|
+
const cards = input.entries.map((entry) =>
|
|
30
|
+
h(
|
|
31
|
+
'li',
|
|
32
|
+
{ class: 'cg-archive-card' },
|
|
33
|
+
dateBadge(entry.publishedAt),
|
|
34
|
+
h(
|
|
35
|
+
'div',
|
|
36
|
+
{ class: 'cg-archive-card__body' },
|
|
37
|
+
h(
|
|
38
|
+
'h2',
|
|
39
|
+
{ class: 'cg-archive-card__title' },
|
|
40
|
+
entry.href === null
|
|
41
|
+
? entry.title
|
|
42
|
+
: h('a', { class: 'cg-archive-card__link', href: entry.href }, entry.title),
|
|
43
|
+
),
|
|
44
|
+
entry.summary === null
|
|
45
|
+
? null
|
|
46
|
+
: h('p', { class: 'cg-archive-card__excerpt' }, entry.summary),
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return h(
|
|
52
|
+
'main',
|
|
53
|
+
{ class: 'cg-main cg-archive', id: 'cg-main' },
|
|
54
|
+
input.ancestors.length === 0
|
|
55
|
+
? null
|
|
56
|
+
: h(
|
|
57
|
+
'nav',
|
|
58
|
+
{ class: 'cg-archive__breadcrumb', 'aria-label': input.labels.breadcrumb },
|
|
59
|
+
h(
|
|
60
|
+
'ol',
|
|
61
|
+
{},
|
|
62
|
+
...input.ancestors.map((link) => h('li', {}, h('a', { href: link.href }, link.label))),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
h('h1', { class: 'cg-archive__title' }, input.term.label),
|
|
66
|
+
input.children.length === 0
|
|
67
|
+
? null
|
|
68
|
+
: h(
|
|
69
|
+
'ul',
|
|
70
|
+
{ class: 'cg-archive__children', 'aria-label': input.labels.subterms },
|
|
71
|
+
...input.children.map((child) => h('li', {}, h('a', { href: child.href }, child.label))),
|
|
72
|
+
),
|
|
73
|
+
cards.length === 0
|
|
74
|
+
? h('p', { class: 'cg-archive__empty' }, input.labels.empty)
|
|
75
|
+
: h('ul', { class: 'cg-archive__grid' }, ...cards),
|
|
76
|
+
pager(input),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pager(input: TermArchiveInput): HtmlElement | null {
|
|
81
|
+
if (input.page.previousHref === null && input.page.nextHref === null) return null
|
|
82
|
+
return h(
|
|
83
|
+
'nav',
|
|
84
|
+
{ class: 'cg-archive__pager', 'aria-label': input.labels.pagination },
|
|
85
|
+
input.page.previousHref === null
|
|
86
|
+
? null
|
|
87
|
+
: h('a', { rel: 'prev', href: input.page.previousHref }, input.labels.previous),
|
|
88
|
+
input.page.nextHref === null
|
|
89
|
+
? null
|
|
90
|
+
: h('a', { rel: 'next', href: input.page.nextHref }, input.labels.next),
|
|
91
|
+
)
|
|
92
|
+
}
|