@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.
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 +16 -0
  9. package/dist/src/render/blocks/accordion.js.map +1 -0
  10. package/dist/src/render/blocks/collection-list.d.ts +12 -0
  11. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  12. package/dist/src/render/blocks/collection-list.js +64 -0
  13. package/dist/src/render/blocks/collection-list.js.map +1 -0
  14. package/dist/src/render/blocks/cta.d.ts +9 -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 +21 -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 +14 -0
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  32. package/dist/src/render/blocks/gallery.js +27 -0
  33. package/dist/src/render/blocks/gallery.js.map +1 -0
  34. package/dist/src/render/blocks/hero.d.ts +15 -0
  35. package/dist/src/render/blocks/hero.d.ts.map +1 -0
  36. package/dist/src/render/blocks/hero.js +31 -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 +16 -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 +22 -0
  45. package/dist/src/render/blocks/logos.js.map +1 -0
  46. package/dist/src/render/blocks/media-figure.d.ts +12 -0
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  48. package/dist/src/render/blocks/media-figure.js +27 -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 +29 -0
  53. package/dist/src/render/blocks/pricing-table.js.map +1 -0
  54. package/dist/src/render/blocks/prose.d.ts +9 -0
  55. package/dist/src/render/blocks/prose.d.ts.map +1 -0
  56. package/dist/src/render/blocks/prose.js +10 -0
  57. package/dist/src/render/blocks/prose.js.map +1 -0
  58. package/dist/src/render/blocks/quote.d.ts +13 -0
  59. package/dist/src/render/blocks/quote.d.ts.map +1 -0
  60. package/dist/src/render/blocks/quote.js +26 -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 +14 -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 +18 -0
  69. package/dist/src/render/blocks/stats.js.map +1 -0
  70. package/dist/src/render/blocks/testimonial.d.ts +11 -0
  71. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  72. package/dist/src/render/blocks/testimonial.js +20 -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 +106 -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 +3 -0
  83. package/dist/src/render/term-archive.d.ts.map +1 -0
  84. package/dist/src/render/term-archive.js +42 -0
  85. package/dist/src/render/term-archive.js.map +1 -0
  86. package/dist/theme.config.d.ts +15 -0
  87. package/dist/theme.config.d.ts.map +1 -0
  88. package/dist/theme.config.js +45 -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 +47 -0
  93. package/src/render/blocks/collection-list.ts +144 -0
  94. package/src/render/blocks/cta.ts +37 -0
  95. package/src/render/blocks/embed.ts +98 -0
  96. package/src/render/blocks/faq.ts +52 -0
  97. package/src/render/blocks/feature-grid.ts +67 -0
  98. package/src/render/blocks/gallery.ts +47 -0
  99. package/src/render/blocks/hero.ts +60 -0
  100. package/src/render/blocks/logo-strip.ts +31 -0
  101. package/src/render/blocks/logos.ts +52 -0
  102. package/src/render/blocks/media-figure.ts +38 -0
  103. package/src/render/blocks/pricing-table.ts +73 -0
  104. package/src/render/blocks/prose.ts +15 -0
  105. package/src/render/blocks/quote.ts +39 -0
  106. package/src/render/blocks/stat-counter.ts +36 -0
  107. package/src/render/blocks/stats.ts +45 -0
  108. package/src/render/blocks/testimonial.ts +36 -0
  109. package/src/render/chrome.ts +134 -0
  110. package/src/render/render-block.ts +140 -0
  111. package/src/render/term-archive.ts +92 -0
  112. package/src/styles/archive.css +129 -0
  113. package/src/styles/base.css +842 -0
  114. package/src/styles/blocks.css +1002 -0
  115. package/src/styles/theme.css +31 -0
  116. package/src/styles/tokens.css +332 -0
  117. 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
+ }