@cogenta/theme-association 0.3.3 → 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.
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +9 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/render/blocks/accordion.d.ts.map +1 -1
- package/dist/src/render/blocks/accordion.js +13 -10
- package/dist/src/render/blocks/accordion.js.map +1 -1
- package/dist/src/render/blocks/breakdown.d.ts +14 -0
- package/dist/src/render/blocks/breakdown.d.ts.map +1 -0
- package/dist/src/render/blocks/breakdown.js +17 -0
- package/dist/src/render/blocks/breakdown.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +4 -10
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
- package/dist/src/render/blocks/collection-list.js +89 -58
- package/dist/src/render/blocks/collection-list.js.map +1 -1
- package/dist/src/render/blocks/cta.d.ts +0 -5
- package/dist/src/render/blocks/cta.d.ts.map +1 -1
- package/dist/src/render/blocks/cta.js +22 -10
- package/dist/src/render/blocks/cta.js.map +1 -1
- package/dist/src/render/blocks/embed.d.ts.map +1 -1
- package/dist/src/render/blocks/embed.js +30 -22
- package/dist/src/render/blocks/embed.js.map +1 -1
- package/dist/src/render/blocks/faq.d.ts.map +1 -1
- package/dist/src/render/blocks/faq.js +11 -15
- package/dist/src/render/blocks/faq.js.map +1 -1
- package/dist/src/render/blocks/feature-grid.d.ts +6 -0
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
- package/dist/src/render/blocks/feature-grid.js +42 -20
- package/dist/src/render/blocks/feature-grid.js.map +1 -1
- package/dist/src/render/blocks/gallery.d.ts +9 -7
- package/dist/src/render/blocks/gallery.d.ts.map +1 -1
- package/dist/src/render/blocks/gallery.js +22 -16
- package/dist/src/render/blocks/gallery.js.map +1 -1
- package/dist/src/render/blocks/hero.d.ts +23 -9
- package/dist/src/render/blocks/hero.d.ts.map +1 -1
- package/dist/src/render/blocks/hero.js +40 -23
- package/dist/src/render/blocks/hero.js.map +1 -1
- package/dist/src/render/blocks/logo-strip.d.ts +6 -0
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
- package/dist/src/render/blocks/logo-strip.js +7 -9
- package/dist/src/render/blocks/logo-strip.js.map +1 -1
- package/dist/src/render/blocks/logos.d.ts +14 -0
- package/dist/src/render/blocks/logos.d.ts.map +1 -1
- package/dist/src/render/blocks/logos.js +30 -16
- package/dist/src/render/blocks/logos.js.map +1 -1
- package/dist/src/render/blocks/media-figure.d.ts +12 -5
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
- package/dist/src/render/blocks/media-figure.js +32 -18
- package/dist/src/render/blocks/media-figure.js.map +1 -1
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
- package/dist/src/render/blocks/pricing-table.js +18 -20
- package/dist/src/render/blocks/pricing-table.js.map +1 -1
- package/dist/src/render/blocks/prose.d.ts +9 -3
- package/dist/src/render/blocks/prose.d.ts.map +1 -1
- package/dist/src/render/blocks/prose.js +11 -4
- package/dist/src/render/blocks/prose.js.map +1 -1
- package/dist/src/render/blocks/quote.d.ts +4 -7
- package/dist/src/render/blocks/quote.d.ts.map +1 -1
- package/dist/src/render/blocks/quote.js +12 -17
- package/dist/src/render/blocks/quote.js.map +1 -1
- package/dist/src/render/blocks/stat-counter.d.ts +8 -1
- package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
- package/dist/src/render/blocks/stat-counter.js +18 -10
- package/dist/src/render/blocks/stat-counter.js.map +1 -1
- package/dist/src/render/blocks/stats.d.ts +12 -1
- package/dist/src/render/blocks/stats.d.ts.map +1 -1
- package/dist/src/render/blocks/stats.js +24 -14
- package/dist/src/render/blocks/stats.js.map +1 -1
- package/dist/src/render/blocks/testimonial.d.ts +6 -5
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
- package/dist/src/render/blocks/testimonial.js +13 -11
- package/dist/src/render/blocks/testimonial.js.map +1 -1
- package/dist/src/render/chrome.d.ts +38 -1
- package/dist/src/render/chrome.d.ts.map +1 -1
- package/dist/src/render/chrome.js +117 -91
- package/dist/src/render/chrome.js.map +1 -1
- package/dist/src/render/details.d.ts +79 -0
- package/dist/src/render/details.d.ts.map +1 -0
- package/dist/src/render/details.js +221 -0
- package/dist/src/render/details.js.map +1 -0
- package/dist/src/render/figures.d.ts +29 -0
- package/dist/src/render/figures.d.ts.map +1 -0
- package/dist/src/render/figures.js +59 -0
- package/dist/src/render/figures.js.map +1 -0
- package/dist/src/render/layout.d.ts +31 -0
- package/dist/src/render/layout.d.ts.map +1 -0
- package/dist/src/render/layout.js +46 -0
- package/dist/src/render/layout.js.map +1 -0
- package/dist/src/render/page-head.d.ts +7 -0
- package/dist/src/render/page-head.d.ts.map +1 -0
- package/dist/src/render/page-head.js +95 -0
- package/dist/src/render/page-head.js.map +1 -0
- package/dist/src/render/render-block.d.ts +8 -12
- package/dist/src/render/render-block.d.ts.map +1 -1
- package/dist/src/render/render-block.js +40 -29
- package/dist/src/render/render-block.js.map +1 -1
- package/dist/src/render/strings.d.ts +15 -0
- package/dist/src/render/strings.d.ts.map +1 -0
- package/dist/src/render/strings.js +39 -0
- package/dist/src/render/strings.js.map +1 -0
- package/dist/src/render/term-archive.d.ts.map +1 -1
- package/dist/src/render/term-archive.js +33 -25
- package/dist/src/render/term-archive.js.map +1 -1
- package/dist/src/render/widget-areas.d.ts +14 -0
- package/dist/src/render/widget-areas.d.ts.map +1 -0
- package/dist/src/render/widget-areas.js +21 -0
- package/dist/src/render/widget-areas.js.map +1 -0
- package/dist/theme.config.d.ts +1 -1
- package/dist/theme.config.js +3 -3
- package/dist/theme.config.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +9 -0
- package/src/render/blocks/accordion.ts +28 -22
- package/src/render/blocks/breakdown.ts +42 -0
- package/src/render/blocks/collection-list.ts +234 -103
- package/src/render/blocks/cta.ts +48 -19
- package/src/render/blocks/embed.ts +47 -29
- package/src/render/blocks/faq.ts +22 -31
- package/src/render/blocks/feature-grid.ts +73 -41
- package/src/render/blocks/gallery.ts +32 -26
- package/src/render/blocks/hero.ts +56 -43
- package/src/render/blocks/logo-strip.ts +23 -21
- package/src/render/blocks/logos.ts +45 -40
- package/src/render/blocks/media-figure.ts +49 -24
- package/src/render/blocks/pricing-table.ts +31 -40
- package/src/render/blocks/prose.ts +16 -6
- package/src/render/blocks/quote.ts +34 -27
- package/src/render/blocks/stat-counter.ts +35 -29
- package/src/render/blocks/stats.ts +46 -38
- package/src/render/blocks/testimonial.ts +29 -20
- package/src/render/chrome.ts +165 -91
- package/src/render/details.ts +276 -0
- package/src/render/figures.ts +68 -0
- package/src/render/layout.ts +78 -0
- package/src/render/page-head.ts +193 -0
- package/src/render/render-block.ts +45 -32
- package/src/render/strings.ts +74 -0
- package/src/render/term-archive.ts +94 -58
- package/src/render/widget-areas.ts +24 -0
- package/src/styles/archive.css +38 -102
- package/src/styles/base.css +503 -554
- package/src/styles/blocks.css +913 -666
- package/src/styles/chrome.css +506 -0
- package/src/styles/listings.css +591 -0
- package/src/styles/theme.css +25 -19
- package/src/styles/tokens.css +284 -234
- package/src/styles/widgets.css +1059 -0
- 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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
15
|
+
return section(
|
|
16
|
+
'div',
|
|
17
|
+
'testimonial',
|
|
18
|
+
'ca-story',
|
|
19
|
+
{ 'data-portrait': String(attribution.avatar !== undefined) },
|
|
14
20
|
'figure',
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
'
|
|
28
|
-
{ class: '
|
|
29
|
-
h('span', { class: '
|
|
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: '
|
|
41
|
+
: h('span', { class: 'ca-story__role' }, attribution.role),
|
|
33
42
|
),
|
|
34
43
|
),
|
|
35
44
|
)
|
package/src/render/chrome.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
return `<
|
|
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
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
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
|
|
85
|
-
|
|
131
|
+
const menu =
|
|
132
|
+
links === ''
|
|
86
133
|
? ''
|
|
87
|
-
: `<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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="
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
`${
|
|
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="
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
: `<
|
|
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="
|
|
121
|
-
`<div class="
|
|
122
|
-
`<
|
|
123
|
-
|
|
124
|
-
`${
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
`<div class="
|
|
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
|
-
|
|
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
|
+
}
|