@cogenta/theme-association 0.3.3 → 0.5.4
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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two readings of plain text a charity writes all the time, recognised from
|
|
3
|
+
* the words themselves rather than from a setting contract B does not have.
|
|
4
|
+
*
|
|
5
|
+
* - **A breakdown.** Figures that are all percentages and add up to a whole
|
|
6
|
+
* (95 to 105, for rounding) say where the money goes: they are drawn as
|
|
7
|
+
* shares of one total, with a bar each, instead of four unrelated numbers.
|
|
8
|
+
* - **What a gift pays for.** A call to action whose sentences start with an
|
|
9
|
+
* amount of money ("£12 buys a week of fresh vegetables for one family.")
|
|
10
|
+
* lists those amounts large beside what they pay for. Any other text stays
|
|
11
|
+
* the paragraph it was written as.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export function shareOf(value: string, unit: string | undefined): number | undefined {
|
|
15
|
+
const joined = unit === undefined ? value.trim() : `${value.trim()}${unit.trim()}`
|
|
16
|
+
const match = /^(\d{1,3}(?:[.,]\d+)?)\s*%$/.exec(joined)
|
|
17
|
+
if (match === null) return undefined
|
|
18
|
+
const share = Number.parseFloat((match[1] as string).replace(',', '.'))
|
|
19
|
+
return share >= 0 && share <= 100 ? share : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isBreakdown(
|
|
23
|
+
items: readonly { readonly value: string; readonly unit?: string | undefined }[],
|
|
24
|
+
): boolean {
|
|
25
|
+
if (items.length < 2) return false
|
|
26
|
+
const shares = items.map((item) => shareOf(item.value, item.unit))
|
|
27
|
+
if (shares.some((share) => share === undefined)) return false
|
|
28
|
+
const total = (shares as number[]).reduce((sum, share) => sum + share, 0)
|
|
29
|
+
return total >= 95 && total <= 105
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Gift {
|
|
33
|
+
readonly amount: string
|
|
34
|
+
readonly text: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GiftList {
|
|
38
|
+
readonly lead: string | undefined
|
|
39
|
+
readonly gifts: readonly Gift[]
|
|
40
|
+
readonly tail: string | undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const GIFT = /^([£€$]\s?\d[\d,.]*(?:\s(?:a|per|each)\s(?:week|month|year))?)\s+(\S[\s\S]*)$/u
|
|
44
|
+
|
|
45
|
+
/** The sentences of `text` that start with an amount, or `undefined` when fewer than two do. */
|
|
46
|
+
export function giftsOf(text: string | undefined): GiftList | undefined {
|
|
47
|
+
if (text === undefined) return undefined
|
|
48
|
+
const sentences = text
|
|
49
|
+
.trim()
|
|
50
|
+
.split(/(?<=[.;])\s+/)
|
|
51
|
+
.filter((sentence) => sentence !== '')
|
|
52
|
+
const first = sentences.findIndex((sentence) => GIFT.test(sentence))
|
|
53
|
+
if (first === -1) return undefined
|
|
54
|
+
let last = first
|
|
55
|
+
while (last + 1 < sentences.length && GIFT.test(sentences[last + 1] as string)) last += 1
|
|
56
|
+
const gifts = sentences.slice(first, last + 1).map((sentence) => {
|
|
57
|
+
const match = GIFT.exec(sentence) as RegExpExecArray
|
|
58
|
+
return { amount: match[1] as string, text: match[2] as string }
|
|
59
|
+
})
|
|
60
|
+
if (gifts.length < 2) return undefined
|
|
61
|
+
const lead = sentences.slice(0, first).join(' ')
|
|
62
|
+
const tail = sentences.slice(last + 1).join(' ')
|
|
63
|
+
return {
|
|
64
|
+
lead: lead === '' ? undefined : lead,
|
|
65
|
+
gifts,
|
|
66
|
+
tail: tail === '' ? undefined : tail,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Attributes,
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type Child,
|
|
5
|
+
type HtmlElement,
|
|
6
|
+
h,
|
|
7
|
+
heading,
|
|
8
|
+
} from '@cogenta/theme-kit'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The frame every block of this theme renders into.
|
|
12
|
+
*
|
|
13
|
+
* The outer element carries the one section rhythm (`--ca-section`, the same
|
|
14
|
+
* for every block) and paints edge to edge when a block is a band. The inner
|
|
15
|
+
* element is the twelve-column container, so a section title, a figure, a
|
|
16
|
+
* date and a caption all start on the same line.
|
|
17
|
+
*
|
|
18
|
+
* `inner` is `figure` for a block whose semantics are a figure (a quotation
|
|
19
|
+
* and its attribution, an image and its caption): `<figcaption>` must be a
|
|
20
|
+
* direct child of its `<figure>`.
|
|
21
|
+
*/
|
|
22
|
+
export function section(
|
|
23
|
+
outer: 'section' | 'div',
|
|
24
|
+
block: string,
|
|
25
|
+
className: string,
|
|
26
|
+
attrs: Attributes,
|
|
27
|
+
inner: 'div' | 'figure',
|
|
28
|
+
...children: readonly Child[]
|
|
29
|
+
): HtmlElement {
|
|
30
|
+
return h(
|
|
31
|
+
outer,
|
|
32
|
+
{ class: `ca-section ${className}`, 'data-block': block, ...attrs },
|
|
33
|
+
h(inner, { class: `ca-container ${className}__inner` }, ...children),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A titled block's heading, in the display face at the section size, on the
|
|
39
|
+
* first column of the grid. `null` for an untitled block: no empty heading.
|
|
40
|
+
*/
|
|
41
|
+
export function sectionHead(blockName: string, title: string | undefined): HtmlElement | null {
|
|
42
|
+
if (title === undefined) return null
|
|
43
|
+
return h(
|
|
44
|
+
'div',
|
|
45
|
+
{ class: 'ca-head' },
|
|
46
|
+
heading(
|
|
47
|
+
blockHeadingTag(blockName) ?? 'h2',
|
|
48
|
+
{ class: 'ca-head__title', 'data-field': 'title' },
|
|
49
|
+
title,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The words of an arrow link, with the last word wrapped in a span that
|
|
56
|
+
* cannot break and that carries the arrow (`::after`, drawn by the
|
|
57
|
+
* stylesheet). A line can then break between words but never between the
|
|
58
|
+
* last word and its arrow, so an arrow is never left alone at the start of a
|
|
59
|
+
* line, and the words keep one continuous underline: the arrow is an atomic
|
|
60
|
+
* inline, which a text decoration does not reach.
|
|
61
|
+
*/
|
|
62
|
+
export function arrowWords(text: string): Child[] {
|
|
63
|
+
const trimmed = text.trim()
|
|
64
|
+
const at = trimmed.lastIndexOf(' ')
|
|
65
|
+
const end = h('span', { class: 'ca-arrow-link__end' }, trimmed.slice(at + 1))
|
|
66
|
+
return at === -1 ? [end] : [trimmed.slice(0, at + 1), end]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A paragraph that renders only when the text is there: no empty `<p>` on the page. */
|
|
70
|
+
export function optionalText(
|
|
71
|
+
tag: string,
|
|
72
|
+
className: string,
|
|
73
|
+
value: string | undefined,
|
|
74
|
+
attrs: Attributes = {},
|
|
75
|
+
): HtmlElement | null {
|
|
76
|
+
if (value === undefined || value.trim() === '') return null
|
|
77
|
+
return h(tag, { class: className, ...attrs }, value)
|
|
78
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type HtmlElement,
|
|
3
|
+
h,
|
|
4
|
+
type PageContent,
|
|
5
|
+
type PageEntryMeta,
|
|
6
|
+
type RenderContext,
|
|
7
|
+
renderImageSource,
|
|
8
|
+
} from '@cogenta/theme-kit'
|
|
9
|
+
import { eventTimeOf, factsOf, renderDateBlock, renderFacts } from './details.js'
|
|
10
|
+
import { associationString } from './strings.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The opening of every page that has no hero. Three kinds, and exactly one
|
|
14
|
+
* `h1` in each:
|
|
15
|
+
*
|
|
16
|
+
* - **An event** (its `theme@1.5` fields carry a date): the date block large
|
|
17
|
+
* on the first three columns, the way a noticeboard poster leads with the
|
|
18
|
+
* day; the title, the summary and the practical details (when, where, what
|
|
19
|
+
* it costs, whether to book) on the rest; the photograph under them across
|
|
20
|
+
* the container.
|
|
21
|
+
* - **An entry with details or a photograph** (a programme, a story): the
|
|
22
|
+
* title, the summary and the details on the first seven columns; a portrait
|
|
23
|
+
* photograph beside them on the last four, a landscape one under them.
|
|
24
|
+
* - **Anything else** (about, donate, a privacy notice): the title large on
|
|
25
|
+
* the grid, and the summary under it when there is one.
|
|
26
|
+
*
|
|
27
|
+
* A host older than `theme@1.5` sends no fields, so its event pages open like
|
|
28
|
+
* any other entry: title, summary and photograph, never a date read from
|
|
29
|
+
* somewhere it was not given. A host older than `theme@1.4` sends no entry at
|
|
30
|
+
* all, and the page opens on its bare title.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
function formatDate(iso: string, locale: string): string {
|
|
34
|
+
try {
|
|
35
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(
|
|
36
|
+
new Date(iso),
|
|
37
|
+
)
|
|
38
|
+
} catch {
|
|
39
|
+
return iso
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Date, author and reading time, for an entry that has a publication date. */
|
|
44
|
+
function meta(entry: PageEntryMeta, ctx: RenderContext): HtmlElement | null {
|
|
45
|
+
if (entry.publishedAt === undefined) return null
|
|
46
|
+
return h(
|
|
47
|
+
'p',
|
|
48
|
+
{ class: 'ca-page-head__meta' },
|
|
49
|
+
h('time', { datetime: entry.publishedAt }, formatDate(entry.publishedAt, ctx.locale)),
|
|
50
|
+
entry.author === undefined ? null : h('span', {}, entry.author.name),
|
|
51
|
+
entry.readingMinutes === undefined
|
|
52
|
+
? null
|
|
53
|
+
: h('span', {}, ctx.t('entry.readingTime', { minutes: entry.readingMinutes })),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function terms(entry: PageEntryMeta): HtmlElement | null {
|
|
58
|
+
if (entry.terms === undefined || entry.terms.length === 0) return null
|
|
59
|
+
return h(
|
|
60
|
+
'ul',
|
|
61
|
+
{ class: 'ca-page-head__terms' },
|
|
62
|
+
entry.terms.map((term) =>
|
|
63
|
+
h('li', {}, term.href === null ? term.label : h('a', { href: term.href }, term.label)),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function details(entry: PageEntryMeta, ctx: RenderContext, className: string): HtmlElement | null {
|
|
69
|
+
const facts = renderFacts(factsOf(entry.fields ?? {}, ctx.locale), 'ca-facts')
|
|
70
|
+
if (facts === null) return null
|
|
71
|
+
return h(
|
|
72
|
+
'div',
|
|
73
|
+
{ class: className },
|
|
74
|
+
h('h2', { class: 'cg-visually-hidden' }, associationString(ctx.locale, 'details')),
|
|
75
|
+
facts,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderEventHead(
|
|
80
|
+
page: PageContent,
|
|
81
|
+
entry: PageEntryMeta,
|
|
82
|
+
ctx: RenderContext,
|
|
83
|
+
): HtmlElement | null {
|
|
84
|
+
const time = eventTimeOf(entry.fields)
|
|
85
|
+
if (time === undefined) return null
|
|
86
|
+
return h(
|
|
87
|
+
'header',
|
|
88
|
+
{ class: 'ca-event', 'data-cover': String(entry.image !== undefined) },
|
|
89
|
+
h(
|
|
90
|
+
'div',
|
|
91
|
+
{ class: 'ca-container ca-event__inner' },
|
|
92
|
+
h(
|
|
93
|
+
'div',
|
|
94
|
+
{ class: 'ca-event__date' },
|
|
95
|
+
renderDateBlock(time, ctx.locale, 'ca-when', { year: true }),
|
|
96
|
+
),
|
|
97
|
+
h(
|
|
98
|
+
'div',
|
|
99
|
+
{ class: 'ca-event__heading' },
|
|
100
|
+
terms(entry),
|
|
101
|
+
h('h1', { class: 'ca-event__title' }, page.title),
|
|
102
|
+
entry.excerpt === undefined ? null : h('p', { class: 'ca-event__lead' }, entry.excerpt),
|
|
103
|
+
),
|
|
104
|
+
details(entry, ctx, 'ca-event__details'),
|
|
105
|
+
entry.image === undefined
|
|
106
|
+
? null
|
|
107
|
+
: h(
|
|
108
|
+
'figure',
|
|
109
|
+
{ class: 'ca-event__cover' },
|
|
110
|
+
renderImageSource(entry.image, {
|
|
111
|
+
className: 'ca-event__image',
|
|
112
|
+
loading: 'eager',
|
|
113
|
+
sizes: '(min-width: 82rem) 78rem, 100vw',
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderEntryHead(
|
|
121
|
+
page: PageContent,
|
|
122
|
+
entry: PageEntryMeta,
|
|
123
|
+
ctx: RenderContext,
|
|
124
|
+
): HtmlElement | null {
|
|
125
|
+
const facts = details(entry, ctx, 'ca-entry__details')
|
|
126
|
+
const cover = entry.image
|
|
127
|
+
if (facts === null && cover === undefined) return null
|
|
128
|
+
const orientation =
|
|
129
|
+
cover === undefined ? 'none' : cover.height > cover.width ? 'portrait' : 'landscape'
|
|
130
|
+
return h(
|
|
131
|
+
'header',
|
|
132
|
+
{ class: 'ca-entry', 'data-cover': orientation },
|
|
133
|
+
h(
|
|
134
|
+
'div',
|
|
135
|
+
{ class: 'ca-container ca-entry__inner' },
|
|
136
|
+
h(
|
|
137
|
+
'div',
|
|
138
|
+
{ class: 'ca-entry__heading' },
|
|
139
|
+
terms(entry),
|
|
140
|
+
h('h1', { class: 'ca-entry__title' }, page.title),
|
|
141
|
+
entry.excerpt === undefined ? null : h('p', { class: 'ca-entry__lead' }, entry.excerpt),
|
|
142
|
+
meta(entry, ctx),
|
|
143
|
+
facts,
|
|
144
|
+
),
|
|
145
|
+
cover === undefined
|
|
146
|
+
? null
|
|
147
|
+
: h(
|
|
148
|
+
'figure',
|
|
149
|
+
{ class: 'ca-entry__cover' },
|
|
150
|
+
renderImageSource(cover, {
|
|
151
|
+
className: 'ca-entry__image',
|
|
152
|
+
loading: 'eager',
|
|
153
|
+
sizes:
|
|
154
|
+
orientation === 'portrait'
|
|
155
|
+
? '(min-width: 64rem) 30vw, 100vw'
|
|
156
|
+
: '(min-width: 82rem) 78rem, 100vw',
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderPlainHead(page: PageContent, ctx: RenderContext): HtmlElement {
|
|
164
|
+
const entry = page.entry
|
|
165
|
+
return h(
|
|
166
|
+
'header',
|
|
167
|
+
{ class: 'ca-page-head' },
|
|
168
|
+
h(
|
|
169
|
+
'div',
|
|
170
|
+
{ class: 'ca-container ca-page-head__inner' },
|
|
171
|
+
entry === undefined ? null : terms(entry),
|
|
172
|
+
h('h1', { class: 'ca-page-head__title' }, page.title),
|
|
173
|
+
entry?.excerpt === undefined ? null : h('p', { class: 'ca-page-head__lead' }, entry.excerpt),
|
|
174
|
+
entry === undefined ? null : meta(entry, ctx),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type PageHeadKind = 'event' | 'entry' | 'page'
|
|
180
|
+
|
|
181
|
+
export function renderPageHead(
|
|
182
|
+
page: PageContent,
|
|
183
|
+
ctx: RenderContext,
|
|
184
|
+
): { readonly kind: PageHeadKind; readonly node: HtmlElement } {
|
|
185
|
+
const entry = page.entry
|
|
186
|
+
if (entry !== undefined) {
|
|
187
|
+
const eventHead = renderEventHead(page, entry, ctx)
|
|
188
|
+
if (eventHead !== null) return { kind: 'event', node: eventHead }
|
|
189
|
+
const entryHead = renderEntryHead(page, entry, ctx)
|
|
190
|
+
if (entryHead !== null) return { kind: 'entry', node: entryHead }
|
|
191
|
+
}
|
|
192
|
+
return { kind: 'page', node: renderPlainHead(page, ctx) }
|
|
193
|
+
}
|
|
@@ -5,8 +5,8 @@ import {
|
|
|
5
5
|
h,
|
|
6
6
|
type PageContent,
|
|
7
7
|
pageHasOwnHeading,
|
|
8
|
+
providedBlockNode,
|
|
8
9
|
type RenderContext,
|
|
9
|
-
renderEntryHeader,
|
|
10
10
|
resolveBlockForRender,
|
|
11
11
|
withBlockKey,
|
|
12
12
|
withBlockVariant,
|
|
@@ -28,9 +28,9 @@ import { renderQuote } from './blocks/quote.js'
|
|
|
28
28
|
import { renderStatCounter } from './blocks/stat-counter.js'
|
|
29
29
|
import { renderStats } from './blocks/stats.js'
|
|
30
30
|
import { renderTestimonial } from './blocks/testimonial.js'
|
|
31
|
+
import { renderPageHead } from './page-head.js'
|
|
31
32
|
|
|
32
33
|
export type { FetchedEntries, PageContent }
|
|
33
|
-
export { pageHasOwnHeading, renderEntryHeader, withBlockKey }
|
|
34
34
|
|
|
35
35
|
export function renderBlock(
|
|
36
36
|
block: VocabularyBlock,
|
|
@@ -38,16 +38,19 @@ export function renderBlock(
|
|
|
38
38
|
entries: FetchedEntries = {},
|
|
39
39
|
registry?: BlockRegistry,
|
|
40
40
|
): HtmlElement | null {
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
41
|
+
// A block a plugin provides arrives already rendered by the host, in a
|
|
42
|
+
// process of its own (contract D theme@1.7). Nothing else in this theme
|
|
43
|
+
// needs to know such blocks exist.
|
|
44
|
+
const provided = providedBlockNode(block, ctx)
|
|
45
|
+
if (provided !== null) return withBlockVariant(provided, block.variant)
|
|
46
|
+
// A stored block is not always literally one of the shared vocabulary:
|
|
47
|
+
// resolving here turns an unimplemented theme-private block into its
|
|
48
|
+
// declared fallback instead of a silently blank slot.
|
|
45
49
|
const resolved = resolveBlockForRender(block, VOCABULARY_NAMES, registry)
|
|
46
50
|
if (resolved === null) return null
|
|
47
51
|
const known = resolved as unknown as VocabularyBlock
|
|
48
|
-
// `
|
|
49
|
-
//
|
|
50
|
-
// `variant` is envelope data every block carries identically.
|
|
52
|
+
// `variant` is envelope data every block carries identically, applied once
|
|
53
|
+
// here rather than by each of the seventeen renderers.
|
|
51
54
|
return withBlockVariant(renderKnownBlock(known, ctx, entries), known.variant)
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -92,12 +95,9 @@ function renderKnownBlock(
|
|
|
92
95
|
case 'logoStrip':
|
|
93
96
|
return renderLogoStrip(known, ctx)
|
|
94
97
|
default: {
|
|
95
|
-
// Exhaustive over contract B
|
|
96
|
-
//
|
|
97
|
-
//
|
|
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.
|
|
98
|
+
// Exhaustive over contract B: `known` is `never` here, so a block this
|
|
99
|
+
// package does not implement stops it compiling until it is. `null`
|
|
100
|
+
// rather than a throw: choosing a fallback is the render layer's job.
|
|
101
101
|
const unreachable: never = known
|
|
102
102
|
void unreachable
|
|
103
103
|
return null
|
|
@@ -106,18 +106,26 @@ function renderKnownBlock(
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
109
|
+
* A real `id` beside `withBlockKey`'s `data-block-key`, so a link to
|
|
110
|
+
* `/donate#<key>` has somewhere to land. Contract B's `_key` is unique within
|
|
111
|
+
* one page's block list, so it can never collide on a page this theme
|
|
112
|
+
* rendered.
|
|
113
|
+
*/
|
|
114
|
+
function withAnchorId(element: HtmlElement | null, key: string): HtmlElement | null {
|
|
115
|
+
if (element === null) return null
|
|
116
|
+
return { ...element, attrs: { ...element.attrs, id: key } }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `<main id="cg-main">` is mandatory: it is the skip link's target, written
|
|
121
|
+
* by the host outside any theme's control.
|
|
111
122
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* is the right markup in both of those cases and only those, so the page
|
|
116
|
-
* always carries exactly one `<h1>`.
|
|
123
|
+
* A page whose blocks include a `hero` lets the hero carry the title; any
|
|
124
|
+
* other page opens on `renderPageHead` (an event, an entry with details, or
|
|
125
|
+
* a plain title), so every page has exactly one `h1`.
|
|
117
126
|
*
|
|
118
|
-
* `withBlockKey` stamps every rendered block with its contract-B `_key
|
|
119
|
-
*
|
|
120
|
-
* rendered iframe back to the block that produced it.
|
|
127
|
+
* `withBlockKey` stamps every rendered block with its contract-B `_key`, so
|
|
128
|
+
* the visual page builder (L16) can map a clicked element back to its block.
|
|
121
129
|
*/
|
|
122
130
|
export function renderPage(
|
|
123
131
|
page: PageContent,
|
|
@@ -125,16 +133,21 @@ export function renderPage(
|
|
|
125
133
|
entries: FetchedEntries = {},
|
|
126
134
|
registry?: BlockRegistry,
|
|
127
135
|
): HtmlElement {
|
|
128
|
-
const
|
|
136
|
+
const head = pageHasOwnHeading(page.blocks) ? null : renderPageHead(page, ctx)
|
|
137
|
+
|
|
129
138
|
return h(
|
|
130
139
|
'main',
|
|
131
|
-
{
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
{
|
|
141
|
+
class: 'cg-main ca-main',
|
|
142
|
+
id: 'cg-main',
|
|
143
|
+
'data-opening': head === null ? 'hero' : head.kind,
|
|
144
|
+
},
|
|
145
|
+
head?.node ?? null,
|
|
136
146
|
page.blocks.map((block) =>
|
|
137
|
-
|
|
147
|
+
withAnchorId(
|
|
148
|
+
withBlockKey(renderBlock(block, ctx, entries, registry), block._key),
|
|
149
|
+
block._key,
|
|
150
|
+
),
|
|
138
151
|
),
|
|
139
152
|
)
|
|
140
153
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { interpolate, themeLocaleFor } from '@cogenta/theme-kit'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The few words a charity's own pages need that the shared vocabulary does
|
|
5
|
+
* not: the labels of an event's or a programme's details, the menu button,
|
|
6
|
+
* and the joining words of a time range.
|
|
7
|
+
*
|
|
8
|
+
* `@cogenta/theme-kit`'s `THEME_STRINGS` carries the strings every theme's
|
|
9
|
+
* blocks share; these belong to one register, so they live with the theme
|
|
10
|
+
* that draws them, in the two languages the shared table knows. A locale the
|
|
11
|
+
* table does not know reads English, the same rule `createThemeTranslator`
|
|
12
|
+
* follows.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type AssociationStringKey =
|
|
16
|
+
| 'when'
|
|
17
|
+
| 'where'
|
|
18
|
+
| 'address'
|
|
19
|
+
| 'cost'
|
|
20
|
+
| 'booking'
|
|
21
|
+
| 'audience'
|
|
22
|
+
| 'contact'
|
|
23
|
+
| 'details'
|
|
24
|
+
| 'timeRange'
|
|
25
|
+
| 'menu'
|
|
26
|
+
| 'close'
|
|
27
|
+
| 'primaryNav'
|
|
28
|
+
| 'footerNav'
|
|
29
|
+
| 'share'
|
|
30
|
+
|
|
31
|
+
const STRINGS: Readonly<Record<'en' | 'fr', Readonly<Record<AssociationStringKey, string>>>> = {
|
|
32
|
+
en: {
|
|
33
|
+
when: 'When',
|
|
34
|
+
where: 'Where',
|
|
35
|
+
address: 'Address',
|
|
36
|
+
cost: 'Cost',
|
|
37
|
+
booking: 'Booking',
|
|
38
|
+
audience: 'Who it is for',
|
|
39
|
+
contact: 'Contact',
|
|
40
|
+
details: 'Details',
|
|
41
|
+
timeRange: '{{start}} to {{end}}',
|
|
42
|
+
menu: 'Menu',
|
|
43
|
+
close: 'Close',
|
|
44
|
+
primaryNav: 'Primary',
|
|
45
|
+
footerNav: 'Footer',
|
|
46
|
+
share: 'Share of the total',
|
|
47
|
+
},
|
|
48
|
+
fr: {
|
|
49
|
+
when: 'Quand',
|
|
50
|
+
where: 'Où',
|
|
51
|
+
address: 'Adresse',
|
|
52
|
+
cost: 'Tarif',
|
|
53
|
+
booking: 'Inscription',
|
|
54
|
+
audience: 'Pour qui',
|
|
55
|
+
contact: 'Contact',
|
|
56
|
+
details: 'Informations pratiques',
|
|
57
|
+
timeRange: 'de {{start}} à {{end}}',
|
|
58
|
+
menu: 'Menu',
|
|
59
|
+
close: 'Fermer',
|
|
60
|
+
primaryNav: 'Navigation principale',
|
|
61
|
+
footerNav: 'Pied de page',
|
|
62
|
+
share: 'Part du total',
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function associationString(
|
|
67
|
+
locale: string,
|
|
68
|
+
key: AssociationStringKey,
|
|
69
|
+
values?: Readonly<Record<string, string | number>>,
|
|
70
|
+
): string {
|
|
71
|
+
return interpolate(STRINGS[themeLocaleFor(locale)][key], values)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type { AssociationStringKey }
|