@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.
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
@@ -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
- // `block`'s type says `VocabularyBlock`, but the value crossing this
42
- // boundary from stored content is not always literally one of the shared
43
- // vocabulary — resolving here is what turns an unimplemented theme-private
44
- // block into its declared fallback instead of a silently blank slot.
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
- // `withBlockVariant` (blocks@2.0, RFC 0002) is applied once here, after
49
- // dispatch, rather than inside each of the seventeen block renderers —
50
- // `variant` is envelope data every block carries identically.
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's vocabulary: `known` is `never` here, so
96
- // a block this package does not implement stops it compiling until it
97
- // is. Returning null rather than throwing is deliberate — a theme has
98
- // no access to `@cogenta/core`'s error types (contract D refuses the
99
- // import), and choosing a fallback block is the render layer's job,
100
- // not the theme's.
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
- * `<main id="cg-main">` is mandatory: it is the skip-link's target, written
110
- * once by `@cogenta/cli`'s `theme-render.ts` outside any theme's control.
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
- * `theme@1.4` (L25 D2): `renderEntryHeader` returns `null` both for a page
113
- * with no `entry` meta and for one whose blocks already draw their own
114
- * heading (a `hero`) — the bare `<h1 class="cg-page__title">` fallback below
115
- * is the right markup in both of those cases and only those, so the page
116
- * always carries exactly one `<h1>`.
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
- * required so the visual page builder (L16) can map a clicked element in the
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 entryHeader = renderEntryHeader(page, ctx)
136
+ const head = pageHasOwnHeading(page.blocks) ? null : renderPageHead(page, ctx)
137
+
129
138
  return h(
130
139
  'main',
131
- { class: 'cg-main', id: 'cg-main' },
132
- entryHeader,
133
- entryHeader === null && !pageHasOwnHeading(page.blocks)
134
- ? h('h1', { class: 'cg-page__title' }, page.title)
135
- : null,
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
- withBlockKey(renderBlock(block, ctx, entries, registry), block._key),
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 }