@cogenta/theme-association 0.3.2 → 0.5.3

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 (148) hide show
  1. package/dist/src/index.d.ts +6 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +9 -0
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/render/blocks/accordion.d.ts.map +1 -1
  6. package/dist/src/render/blocks/accordion.js +13 -10
  7. package/dist/src/render/blocks/accordion.js.map +1 -1
  8. package/dist/src/render/blocks/breakdown.d.ts +14 -0
  9. package/dist/src/render/blocks/breakdown.d.ts.map +1 -0
  10. package/dist/src/render/blocks/breakdown.js +17 -0
  11. package/dist/src/render/blocks/breakdown.js.map +1 -0
  12. package/dist/src/render/blocks/collection-list.d.ts +4 -10
  13. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  14. package/dist/src/render/blocks/collection-list.js +89 -58
  15. package/dist/src/render/blocks/collection-list.js.map +1 -1
  16. package/dist/src/render/blocks/cta.d.ts +0 -5
  17. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  18. package/dist/src/render/blocks/cta.js +22 -10
  19. package/dist/src/render/blocks/cta.js.map +1 -1
  20. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  21. package/dist/src/render/blocks/embed.js +30 -22
  22. package/dist/src/render/blocks/embed.js.map +1 -1
  23. package/dist/src/render/blocks/faq.d.ts.map +1 -1
  24. package/dist/src/render/blocks/faq.js +11 -15
  25. package/dist/src/render/blocks/faq.js.map +1 -1
  26. package/dist/src/render/blocks/feature-grid.d.ts +6 -0
  27. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  28. package/dist/src/render/blocks/feature-grid.js +42 -20
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  30. package/dist/src/render/blocks/gallery.d.ts +9 -7
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  32. package/dist/src/render/blocks/gallery.js +22 -16
  33. package/dist/src/render/blocks/gallery.js.map +1 -1
  34. package/dist/src/render/blocks/hero.d.ts +23 -9
  35. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  36. package/dist/src/render/blocks/hero.js +40 -23
  37. package/dist/src/render/blocks/hero.js.map +1 -1
  38. package/dist/src/render/blocks/logo-strip.d.ts +6 -0
  39. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
  40. package/dist/src/render/blocks/logo-strip.js +7 -9
  41. package/dist/src/render/blocks/logo-strip.js.map +1 -1
  42. package/dist/src/render/blocks/logos.d.ts +14 -0
  43. package/dist/src/render/blocks/logos.d.ts.map +1 -1
  44. package/dist/src/render/blocks/logos.js +30 -16
  45. package/dist/src/render/blocks/logos.js.map +1 -1
  46. package/dist/src/render/blocks/media-figure.d.ts +12 -5
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  48. package/dist/src/render/blocks/media-figure.js +32 -18
  49. package/dist/src/render/blocks/media-figure.js.map +1 -1
  50. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  51. package/dist/src/render/blocks/pricing-table.js +18 -20
  52. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  53. package/dist/src/render/blocks/prose.d.ts +9 -3
  54. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  55. package/dist/src/render/blocks/prose.js +11 -4
  56. package/dist/src/render/blocks/prose.js.map +1 -1
  57. package/dist/src/render/blocks/quote.d.ts +4 -7
  58. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  59. package/dist/src/render/blocks/quote.js +12 -17
  60. package/dist/src/render/blocks/quote.js.map +1 -1
  61. package/dist/src/render/blocks/stat-counter.d.ts +8 -1
  62. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
  63. package/dist/src/render/blocks/stat-counter.js +18 -10
  64. package/dist/src/render/blocks/stat-counter.js.map +1 -1
  65. package/dist/src/render/blocks/stats.d.ts +12 -1
  66. package/dist/src/render/blocks/stats.d.ts.map +1 -1
  67. package/dist/src/render/blocks/stats.js +24 -14
  68. package/dist/src/render/blocks/stats.js.map +1 -1
  69. package/dist/src/render/blocks/testimonial.d.ts +6 -5
  70. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  71. package/dist/src/render/blocks/testimonial.js +13 -11
  72. package/dist/src/render/blocks/testimonial.js.map +1 -1
  73. package/dist/src/render/chrome.d.ts +38 -1
  74. package/dist/src/render/chrome.d.ts.map +1 -1
  75. package/dist/src/render/chrome.js +117 -91
  76. package/dist/src/render/chrome.js.map +1 -1
  77. package/dist/src/render/details.d.ts +79 -0
  78. package/dist/src/render/details.d.ts.map +1 -0
  79. package/dist/src/render/details.js +221 -0
  80. package/dist/src/render/details.js.map +1 -0
  81. package/dist/src/render/figures.d.ts +29 -0
  82. package/dist/src/render/figures.d.ts.map +1 -0
  83. package/dist/src/render/figures.js +59 -0
  84. package/dist/src/render/figures.js.map +1 -0
  85. package/dist/src/render/layout.d.ts +31 -0
  86. package/dist/src/render/layout.d.ts.map +1 -0
  87. package/dist/src/render/layout.js +46 -0
  88. package/dist/src/render/layout.js.map +1 -0
  89. package/dist/src/render/page-head.d.ts +7 -0
  90. package/dist/src/render/page-head.d.ts.map +1 -0
  91. package/dist/src/render/page-head.js +95 -0
  92. package/dist/src/render/page-head.js.map +1 -0
  93. package/dist/src/render/render-block.d.ts +8 -12
  94. package/dist/src/render/render-block.d.ts.map +1 -1
  95. package/dist/src/render/render-block.js +40 -29
  96. package/dist/src/render/render-block.js.map +1 -1
  97. package/dist/src/render/strings.d.ts +15 -0
  98. package/dist/src/render/strings.d.ts.map +1 -0
  99. package/dist/src/render/strings.js +39 -0
  100. package/dist/src/render/strings.js.map +1 -0
  101. package/dist/src/render/term-archive.d.ts.map +1 -1
  102. package/dist/src/render/term-archive.js +33 -25
  103. package/dist/src/render/term-archive.js.map +1 -1
  104. package/dist/src/render/widget-areas.d.ts +14 -0
  105. package/dist/src/render/widget-areas.d.ts.map +1 -0
  106. package/dist/src/render/widget-areas.js +21 -0
  107. package/dist/src/render/widget-areas.js.map +1 -0
  108. package/dist/theme.config.d.ts +1 -1
  109. package/dist/theme.config.js +3 -3
  110. package/dist/theme.config.js.map +1 -1
  111. package/package.json +5 -5
  112. package/src/index.ts +9 -0
  113. package/src/render/blocks/accordion.ts +28 -22
  114. package/src/render/blocks/breakdown.ts +42 -0
  115. package/src/render/blocks/collection-list.ts +234 -103
  116. package/src/render/blocks/cta.ts +48 -19
  117. package/src/render/blocks/embed.ts +47 -29
  118. package/src/render/blocks/faq.ts +22 -31
  119. package/src/render/blocks/feature-grid.ts +73 -41
  120. package/src/render/blocks/gallery.ts +32 -26
  121. package/src/render/blocks/hero.ts +56 -43
  122. package/src/render/blocks/logo-strip.ts +23 -21
  123. package/src/render/blocks/logos.ts +45 -40
  124. package/src/render/blocks/media-figure.ts +49 -24
  125. package/src/render/blocks/pricing-table.ts +31 -40
  126. package/src/render/blocks/prose.ts +16 -6
  127. package/src/render/blocks/quote.ts +34 -27
  128. package/src/render/blocks/stat-counter.ts +35 -29
  129. package/src/render/blocks/stats.ts +46 -38
  130. package/src/render/blocks/testimonial.ts +29 -20
  131. package/src/render/chrome.ts +165 -91
  132. package/src/render/details.ts +276 -0
  133. package/src/render/figures.ts +68 -0
  134. package/src/render/layout.ts +78 -0
  135. package/src/render/page-head.ts +193 -0
  136. package/src/render/render-block.ts +45 -32
  137. package/src/render/strings.ts +74 -0
  138. package/src/render/term-archive.ts +94 -58
  139. package/src/render/widget-areas.ts +24 -0
  140. package/src/styles/archive.css +38 -102
  141. package/src/styles/base.css +503 -554
  142. package/src/styles/blocks.css +913 -666
  143. package/src/styles/chrome.css +506 -0
  144. package/src/styles/listings.css +591 -0
  145. package/src/styles/theme.css +25 -19
  146. package/src/styles/tokens.css +284 -234
  147. package/src/styles/widgets.css +1059 -0
  148. package/tokens.json +18 -18
@@ -1,35 +1,44 @@
1
1
  import type { TestimonialBlock } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, image, type RenderContext, renderRichText } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
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.
6
+ * One person's own account of what the organisation meant to them, told
7
+ * with their photograph: the portrait cropped to 4:5 on the first four
8
+ * columns, the words in the display face on the last seven, starting on a
9
+ * hung quotation mark, and the name and what they do here beneath them.
10
+ * Without a portrait the words take the reading column alone; nothing stands
11
+ * in for the missing face.
10
12
  */
11
13
  export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
12
14
  const { attribution } = block
13
- return h(
15
+ return section(
16
+ 'div',
17
+ 'testimonial',
18
+ 'ca-story',
19
+ { 'data-portrait': String(attribution.avatar !== undefined) },
14
20
  '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' },
21
+ attribution.avatar === undefined
22
+ ? null
23
+ : h(
24
+ 'div',
25
+ { class: 'ca-story__portrait' },
26
+ image(ctx, attribution.avatar, {
27
+ className: 'ca-story__image',
28
+ sizes: '(min-width: 64rem) 26rem, 70vw',
25
29
  }),
30
+ ),
31
+ h(
32
+ 'div',
33
+ { class: 'ca-story__words' },
34
+ h('blockquote', { class: 'ca-story__quote' }, renderRichText(ctx, block.quote)),
26
35
  h(
27
- 'span',
28
- { class: 'cg-testimonial__who' },
29
- h('span', { class: 'cg-testimonial__name' }, attribution.name),
36
+ 'figcaption',
37
+ { class: 'ca-story__attribution' },
38
+ h('span', { class: 'ca-story__name' }, attribution.name),
30
39
  attribution.role === undefined
31
40
  ? null
32
- : h('span', { class: 'cg-testimonial__role' }, attribution.role),
41
+ : h('span', { class: 'ca-story__role' }, attribution.role),
33
42
  ),
34
43
  ),
35
44
  )
@@ -5,130 +5,204 @@ import {
5
5
  escapeAttribute,
6
6
  escapeText,
7
7
  renderBrandMark,
8
+ renderFooterWidgets,
8
9
  renderSocialLinks,
9
10
  renderThemeToggle,
10
11
  serialize,
11
12
  } from '@cogenta/theme-kit'
13
+ import { associationString } from './strings.js'
12
14
 
13
15
  /**
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.
16
+ * The header and footer of a charity's site (`theme@1.4`).
19
17
  *
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.
18
+ * Header: one row on the paper under a hairline. The organisation's name in
19
+ * the display face (or its uploaded logo), the pages in plain words, then the
20
+ * `headerAction` as the yellow button, the one standing ask of a charity's
21
+ * site, and the light/dark control. The action sits outside the navigation
22
+ * list on purpose: on a phone it stays in the header row beside the menu
23
+ * button.
28
24
  *
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.
25
+ * Below the wide breakpoint the pages become a panel of large links under the
26
+ * header, opened by a CSS-only toggle: a visually hidden checkbox paired with
27
+ * a `<label>` that says "Menu" (and "Close" once open), placed before the
28
+ * `<nav>` so the sibling combinator reaches it. One `<nav>` in the markup,
29
+ * retiled by CSS. The panel repeats the `tagline` under the links.
33
30
  *
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.
31
+ * Footer: the green band. The name, the `tagline`, the `footerNote` (a blank
32
+ * line starts a new paragraph, a line break stays a line break, so the
33
+ * registered charity number, the address and the contact lines keep their
34
+ * shape), the social profiles with their names; then the footer navigation in
35
+ * columns: a `submenu-placeholder` (an unlinked item such as "Get involved")
36
+ * starts a column and names it, the links after it fill it. Under a hairline,
37
+ * the legal line: the copyright year and the name, beside the host's
38
+ * `brandingHtml`, placed once, as received. Every `theme@1.4` field renders
39
+ * only when present.
40
+ *
41
+ * Footer widget columns (`theme@1.6`) are one row under the name and the
42
+ * menu, above the legal line, in the band's own small type; with none, the
43
+ * footer is exactly what it was before widgets existed.
38
44
  */
39
45
 
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('')
46
+ interface FooterGroup {
47
+ readonly heading: string | null
48
+ readonly links: readonly ChromeNavLink[]
49
+ }
50
+
51
+ function usable(links: readonly ChromeNavLink[]): readonly ChromeNavLink[] {
52
+ return links.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
52
53
  }
53
54
 
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>`
55
+ function linkItem(link: ChromeNavLink, className: string): string {
56
+ const label = escapeText(link.label)
57
+ const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
58
+ if (link.href === null) return `<li class="${className}"><span${titleAttr}>${label}</span></li>`
59
+ const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
60
+ return `<li class="${className}"><a href="${escapeAttribute(link.href)}"${target}${titleAttr}>${label}</a></li>`
58
61
  }
59
62
 
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
- )
63
+ /** The footer menu, split into columns at each unlinked heading. */
64
+ export function footerGroups(links: readonly ChromeNavLink[]): readonly FooterGroup[] {
65
+ const groups: { heading: string | null; links: ChromeNavLink[] }[] = []
66
+ for (const link of usable(links)) {
67
+ if (link.kind === 'submenu-placeholder' && link.href === null) {
68
+ groups.push({ heading: link.label, links: [] })
69
+ continue
70
+ }
71
+ const current = groups[groups.length - 1]
72
+ if (current === undefined) groups.push({ heading: null, links: [link] })
73
+ else current.links.push(link)
74
+ }
75
+ return groups.filter((group) => group.links.length > 0)
67
76
  }
68
77
 
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>'
78
+ /** `footerNote` as paragraphs at blank lines, each keeping its own line breaks. */
79
+ function renderFooterNote(note: string): string {
80
+ const paragraphs = note
81
+ .replace(/\r\n?/g, '\n')
82
+ .split(/\n\s*\n/)
83
+ .map((paragraph) =>
84
+ paragraph
85
+ .split('\n')
86
+ .map((line) => line.trim())
87
+ .filter((line) => line !== ''),
88
+ )
89
+ .filter((lines) => lines.length > 0)
90
+ if (paragraphs.length === 0) return ''
91
+ return `<div class="ca-footer__note">${paragraphs
92
+ .map((lines) => `<p>${lines.map(noteLine).join('<br>')}</p>`)
93
+ .join('')}</div>`
94
+ }
95
+
96
+ /**
97
+ * A line of the footer note that is nothing but an email address or a
98
+ * telephone number is what a visitor taps to reach the organisation, so it
99
+ * becomes a link; every other line stays text.
100
+ */
101
+ function noteLine(line: string): string {
102
+ const text = escapeText(line)
103
+ if (/^[^\s@<>"']+@[^\s@<>"']+\.[a-z]{2,}$/i.test(line)) {
104
+ return `<a href="mailto:${escapeAttribute(line)}">${text}</a>`
105
+ }
106
+ const digits = line.replace(/[\s().-]/g, '')
107
+ if (/^\+?\d{7,15}$/.test(digits)) return `<a href="tel:${digits}">${text}</a>`
108
+ return text
109
+ }
74
110
 
75
111
  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' }))
112
+ const siteName = escapeText(input.site.name)
113
+ const home = escapeAttribute(input.homeHref)
114
+ const links = usable(input.headerNav)
115
+ .map((link) => linkItem(link, 'ca-header__item'))
116
+ .join('')
117
+ const action =
118
+ input.headerAction === undefined
119
+ ? ''
120
+ : `<a class="ca-header__action" href="${escapeAttribute(input.headerAction.href)}">${escapeText(
121
+ input.headerAction.label,
122
+ )}</a>`
123
+ const toggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
124
+ const logo = renderBrandMark(input.brand, { className: 'ca-header__logo' })
125
+ const mark = logo ?? `<span class="ca-header__wordmark">${siteName}</span>`
126
+ const panelTagline =
127
+ input.tagline === undefined
128
+ ? ''
129
+ : `<p class="ca-header__tagline">${escapeText(input.tagline)}</p>`
83
130
 
84
- const mobileToggle =
85
- headerNavMobile === ''
131
+ const menu =
132
+ links === ''
86
133
  ? ''
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>`
134
+ : `<input type="checkbox" id="ca-nav-toggle" class="ca-nav-toggle-input" aria-label="${escapeAttribute(
135
+ associationString(input.locale, 'menu'),
136
+ )}">` +
137
+ `<label for="ca-nav-toggle" class="ca-nav-toggle-label" aria-hidden="true">` +
138
+ `<span class="ca-nav-toggle-open">${escapeText(associationString(input.locale, 'menu'))}</span>` +
139
+ `<span class="ca-nav-toggle-close">${escapeText(associationString(input.locale, 'close'))}</span>` +
140
+ `</label>` +
141
+ `<nav class="ca-header__nav" id="ca-nav" aria-label="${escapeAttribute(
142
+ associationString(input.locale, 'primaryNav'),
143
+ )}">` +
144
+ `<ul class="ca-header__links">${links}</ul>` +
145
+ `${panelTagline}` +
146
+ `</nav>`
91
147
 
92
148
  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}` +
149
+ `<header class="ca-header" data-nav="${links === '' ? 'none' : 'links'}" data-action="${
150
+ action === '' ? 'none' : 'link'
151
+ }">` +
152
+ `<div class="ca-header__inner">` +
153
+ `<a class="ca-header__brand" href="${home}">${mark}</a>` +
154
+ `${menu}` +
155
+ `${action}` +
156
+ `${toggle}` +
99
157
  `</div></header>`
100
158
 
101
159
  const tagline =
102
160
  input.tagline === undefined
103
161
  ? ''
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
162
+ : `<p class="ca-footer__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
163
+ const note = input.footerNote === undefined ? '' : renderFooterNote(input.footerNote)
164
+ const social = renderSocialLinks(input.social, {
165
+ className: 'ca-footer__social',
166
+ itemClassName: 'ca-footer__social-item',
167
+ })
168
+ const groups = footerGroups(input.footerNav)
169
+ const nav =
170
+ groups.length === 0
116
171
  ? ''
117
- : `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
172
+ : `<nav class="ca-footer__nav" aria-label="${escapeAttribute(
173
+ associationString(input.locale, 'footerNav'),
174
+ )}" data-columns="${Math.min(groups.length, 3)}">${groups
175
+ .map(
176
+ (group) =>
177
+ `<div class="ca-footer__group">${
178
+ group.heading === null
179
+ ? ''
180
+ : `<p class="ca-footer__heading">${escapeText(group.heading)}</p>`
181
+ }<ul class="ca-footer__links">${group.links
182
+ .map((link) => linkItem(link, 'ca-footer__item'))
183
+ .join('')}</ul></div>`,
184
+ )
185
+ .join('')}</nav>`
186
+ const year = new Date().getFullYear()
187
+ const widgets = renderFooterWidgets(input.widgets, {
188
+ className: 'ca-footer__widgets',
189
+ headingLevel: 'h2',
190
+ })
118
191
 
119
192
  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}` +
193
+ `<footer class="ca-footer"><div class="ca-footer__inner">` +
194
+ `<div class="ca-footer__about">` +
195
+ `<a class="ca-footer__name" href="${home}">${siteName}</a>` +
196
+ `${tagline}${note}` +
197
+ `${social === null ? '' : serialize(social)}` +
125
198
  `</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>` +
199
+ `${nav}` +
200
+ `${widgets === null ? '' : serialize(widgets)}` +
201
+ `<div class="ca-footer__legal">` +
202
+ `<p class="ca-footer__copyright">© ${year} ${siteName}</p>` +
203
+ `${input.brandingHtml === '' ? '' : `<div class="ca-footer__branding">${input.brandingHtml}</div>`}` +
129
204
  `</div>` +
130
- `<div class="cg-site-footer__bottom"><span>${siteNameText}</span>${input.brandingHtml}</div>` +
131
- `</footer>`
205
+ `</div></footer>`
132
206
 
133
207
  return { header, footer }
134
208
  }
@@ -0,0 +1,276 @@
1
+ import { type HtmlElement, h } from '@cogenta/theme-kit'
2
+ import { associationString } from './strings.js'
3
+
4
+ /**
5
+ * Reading an event's date and a programme's practical details out of plain
6
+ * content data.
7
+ *
8
+ * Contract A fixes the system fields and nothing else, so a theme can only
9
+ * recognise these by convention: an entry with a date in `startsAt`, `start`,
10
+ * `date` or `eventDate` is something that happens on a day, and its
11
+ * `location`, `address`, `cost` and `booking` fields say where and how to
12
+ * come. The same convention reads an event in a list (the raw entry) and on
13
+ * its own page (`theme@1.5`'s `PageEntryMeta.fields`), so the two can never
14
+ * disagree about a date.
15
+ *
16
+ * Dates are written in UTC. Contract D carries no site time zone, and a
17
+ * server's own zone is an accident of where it happens to run; a stored
18
+ * `2026-10-15T18:00:00Z` therefore reads "6pm" on every server, which is
19
+ * what an editor who typed six o'clock meant.
20
+ */
21
+
22
+ export const START_FIELDS = ['startsAt', 'start', 'date', 'eventDate'] as const
23
+ export const END_FIELDS = ['endsAt', 'end'] as const
24
+
25
+ const PLACE_FIELDS = ['location', 'venue', 'place'] as const
26
+ const SCHEDULE_FIELDS = ['schedule', 'when', 'hours'] as const
27
+
28
+ type Source = Readonly<Record<string, unknown>>
29
+
30
+ export interface Moment {
31
+ readonly iso: string
32
+ readonly date: Date
33
+ /** `false` for a bare `YYYY-MM-DD`: the day is known, the hour is not. */
34
+ readonly hasTime: boolean
35
+ }
36
+
37
+ export interface EventTime {
38
+ readonly start: Moment
39
+ readonly end: Moment | undefined
40
+ }
41
+
42
+ /** A non-empty string, trimmed, or `undefined`. */
43
+ export function textOf(value: unknown): string | undefined {
44
+ if (typeof value !== 'string') return undefined
45
+ const trimmed = value.trim()
46
+ return trimmed === '' ? undefined : trimmed
47
+ }
48
+
49
+ function firstText(source: Source, names: readonly string[]): string | undefined {
50
+ for (const name of names) {
51
+ const value = textOf(source[name])
52
+ if (value !== undefined) return value
53
+ }
54
+ return undefined
55
+ }
56
+
57
+ function momentOf(value: unknown): Moment | undefined {
58
+ const text = textOf(value)
59
+ if (text === undefined || !/^\d{4}-\d{2}-\d{2}/.test(text)) return undefined
60
+ const date = new Date(text)
61
+ if (Number.isNaN(date.getTime())) return undefined
62
+ return { iso: text, date, hasTime: /T\d{2}:\d{2}/.test(text) }
63
+ }
64
+
65
+ /** The start (and end, when there is one after it) of whatever this entry says happens. */
66
+ export function eventTimeOf(source: Source | undefined): EventTime | undefined {
67
+ if (source === undefined) return undefined
68
+ let start: Moment | undefined
69
+ for (const name of START_FIELDS) {
70
+ start = momentOf(source[name])
71
+ if (start !== undefined) break
72
+ }
73
+ if (start === undefined) return undefined
74
+ let end: Moment | undefined
75
+ for (const name of END_FIELDS) {
76
+ end = momentOf(source[name])
77
+ if (end !== undefined) break
78
+ }
79
+ return { start, end: end !== undefined && end.date > start.date ? end : undefined }
80
+ }
81
+
82
+ function format(date: Date, locale: string, options: Intl.DateTimeFormatOptions): string {
83
+ const resolved = { ...options, timeZone: 'UTC' }
84
+ try {
85
+ return new Intl.DateTimeFormat(locale, resolved).format(date)
86
+ } catch {
87
+ return new Intl.DateTimeFormat('en', resolved).format(date)
88
+ }
89
+ }
90
+
91
+ export function dayOf(moment: Moment, locale: string): string {
92
+ return format(moment.date, locale, { day: 'numeric' })
93
+ }
94
+
95
+ export function monthOf(moment: Moment, locale: string): string {
96
+ return format(moment.date, locale, { month: 'short' }).replace(/\.$/, '')
97
+ }
98
+
99
+ export function weekdayOf(moment: Moment, locale: string): string {
100
+ return format(moment.date, locale, { weekday: 'short' }).replace(/\.$/, '')
101
+ }
102
+
103
+ export function yearOf(moment: Moment, locale: string): string {
104
+ return format(moment.date, locale, { year: 'numeric' })
105
+ }
106
+
107
+ export function longDateOf(moment: Moment, locale: string): string {
108
+ return format(moment.date, locale, { weekday: 'long', day: 'numeric', month: 'long' })
109
+ }
110
+
111
+ export function fullDateOf(moment: Moment, locale: string): string {
112
+ return format(moment.date, locale, {
113
+ weekday: 'long',
114
+ day: 'numeric',
115
+ month: 'long',
116
+ year: 'numeric',
117
+ })
118
+ }
119
+
120
+ /**
121
+ * A time of day the way a charity's own printed copy writes it. A locale
122
+ * whose clock is twelve-hour reads "6pm", "7.30pm", "10am": lowercase, no
123
+ * ":00", a dot before the minutes. A locale whose clock is twenty-four-hour
124
+ * keeps the platform's own form ("18:30"). Which clock a locale uses comes
125
+ * from `Intl`, never from a list of countries.
126
+ */
127
+ export function timeOf(moment: Moment, locale: string): string {
128
+ let resolved: Intl.DateTimeFormat
129
+ try {
130
+ resolved = new Intl.DateTimeFormat(locale, { hour: 'numeric', timeZone: 'UTC' })
131
+ } catch {
132
+ resolved = new Intl.DateTimeFormat('en', { hour: 'numeric', timeZone: 'UTC' })
133
+ }
134
+ const cycle = resolved.resolvedOptions().hourCycle
135
+ if (cycle !== 'h12' && cycle !== 'h11') {
136
+ return format(moment.date, locale, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
137
+ }
138
+ const hours = moment.date.getUTCHours()
139
+ const minutes = moment.date.getUTCMinutes()
140
+ const hour = hours % 12 === 0 ? 12 : hours % 12
141
+ const suffix = hours < 12 ? 'am' : 'pm'
142
+ return minutes === 0 ? `${hour}${suffix}` : `${hour}.${String(minutes).padStart(2, '0')}${suffix}`
143
+ }
144
+
145
+ /** "6pm to 7.30pm", "6pm", or `undefined` for an event with no hour. */
146
+ export function hoursOf(time: EventTime, locale: string): string | undefined {
147
+ if (!time.start.hasTime) return undefined
148
+ const start = timeOf(time.start, locale)
149
+ const end = time.end
150
+ if (end === undefined || !end.hasTime) return start
151
+ const sameDay = end.date.toISOString().slice(0, 10) === time.start.date.toISOString().slice(0, 10)
152
+ if (!sameDay) return start
153
+ return associationString(locale, 'timeRange', { start, end: timeOf(end, locale) })
154
+ }
155
+
156
+ export function placeOf(source: Source): string | undefined {
157
+ return firstText(source, PLACE_FIELDS)
158
+ }
159
+
160
+ export interface Fact {
161
+ readonly key: string
162
+ readonly label: string
163
+ readonly value: string
164
+ /** A second line under the value: the street address under the name of a hall. */
165
+ readonly detail?: string
166
+ readonly href?: string
167
+ }
168
+
169
+ function contactHref(value: string): string | undefined {
170
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `mailto:${value}`
171
+ const digits = value.replace(/[\s().-]/g, '')
172
+ if (/^\+?\d{7,15}$/.test(digits)) return `tel:${digits}`
173
+ return undefined
174
+ }
175
+
176
+ /**
177
+ * The practical details a visitor looks for before they come, in the order
178
+ * they look: when, where, who it is for, what it costs, whether to book, and
179
+ * who to ask. An event's "when" is its date; a programme's is its schedule.
180
+ * Only what the entry actually says is listed.
181
+ */
182
+ export function factsOf(source: Source, locale: string): readonly Fact[] {
183
+ const facts: Fact[] = []
184
+ const time = eventTimeOf(source)
185
+ if (time !== undefined) {
186
+ const hours = hoursOf(time, locale)
187
+ facts.push({
188
+ key: 'when',
189
+ label: associationString(locale, 'when'),
190
+ value: fullDateOf(time.start, locale),
191
+ ...(hours === undefined ? {} : { detail: hours }),
192
+ })
193
+ } else {
194
+ const schedule = firstText(source, SCHEDULE_FIELDS)
195
+ if (schedule !== undefined) {
196
+ facts.push({ key: 'when', label: associationString(locale, 'when'), value: schedule })
197
+ }
198
+ }
199
+ const place = placeOf(source)
200
+ const address = textOf(source.address)
201
+ if (place !== undefined) {
202
+ facts.push({
203
+ key: 'where',
204
+ label: associationString(locale, 'where'),
205
+ value: place,
206
+ ...(address === undefined ? {} : { detail: address }),
207
+ })
208
+ } else if (address !== undefined) {
209
+ facts.push({ key: 'where', label: associationString(locale, 'address'), value: address })
210
+ }
211
+ for (const key of ['audience', 'cost', 'booking'] as const) {
212
+ const value = textOf(source[key])
213
+ if (value !== undefined) facts.push({ key, label: associationString(locale, key), value })
214
+ }
215
+ const contact = textOf(source.contact)
216
+ if (contact !== undefined) {
217
+ const href = contactHref(contact)
218
+ facts.push({
219
+ key: 'contact',
220
+ label: associationString(locale, 'contact'),
221
+ value: contact,
222
+ ...(href === undefined ? {} : { href }),
223
+ })
224
+ }
225
+ return facts
226
+ }
227
+
228
+ /** The details as a definition list, or `null` when the entry says none of them. */
229
+ export function renderFacts(facts: readonly Fact[], className: string): HtmlElement | null {
230
+ if (facts.length === 0) return null
231
+ return h(
232
+ 'dl',
233
+ { class: className, 'data-count': String(Math.min(facts.length, 4)) },
234
+ facts.map((fact) =>
235
+ h(
236
+ 'div',
237
+ { class: `${className}-item`, 'data-fact': fact.key },
238
+ h('dt', { class: `${className}-label` }, fact.label),
239
+ h(
240
+ 'dd',
241
+ { class: `${className}-value` },
242
+ fact.href === undefined ? fact.value : h('a', { href: fact.href }, fact.value),
243
+ fact.detail === undefined
244
+ ? null
245
+ : h('span', { class: `${className}-detail` }, fact.detail),
246
+ ),
247
+ ),
248
+ ),
249
+ )
250
+ }
251
+
252
+ /**
253
+ * The typographic date block: the day large, the month in small capitals,
254
+ * the weekday under them. The whole block is one `<time>`, so a reader hears
255
+ * one date rather than three loose words.
256
+ */
257
+ export function renderDateBlock(
258
+ time: EventTime,
259
+ locale: string,
260
+ className: string,
261
+ options: { readonly year?: boolean } = {},
262
+ ): HtmlElement {
263
+ return h(
264
+ 'time',
265
+ { class: className, datetime: time.start.iso },
266
+ h('span', { class: `${className}-month` }, monthOf(time.start, locale)),
267
+ h('span', { class: `${className}-day` }, dayOf(time.start, locale)),
268
+ h(
269
+ 'span',
270
+ { class: `${className}-weekday` },
271
+ options.year === true
272
+ ? `${weekdayOf(time.start, locale)} ${yearOf(time.start, locale)}`
273
+ : weekdayOf(time.start, locale),
274
+ ),
275
+ )
276
+ }