@cogenta/theme-magazine 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +21 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/render/blocks/accordion.d.ts +4 -0
- package/dist/src/render/blocks/accordion.d.ts.map +1 -0
- package/dist/src/render/blocks/accordion.js +20 -0
- package/dist/src/render/blocks/accordion.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +5 -0
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
- package/dist/src/render/blocks/collection-list.js +134 -0
- package/dist/src/render/blocks/collection-list.js.map +1 -0
- package/dist/src/render/blocks/cta.d.ts +10 -0
- package/dist/src/render/blocks/cta.d.ts.map +1 -0
- package/dist/src/render/blocks/cta.js +16 -0
- package/dist/src/render/blocks/cta.js.map +1 -0
- package/dist/src/render/blocks/embed.d.ts +4 -0
- package/dist/src/render/blocks/embed.d.ts.map +1 -0
- package/dist/src/render/blocks/embed.js +76 -0
- package/dist/src/render/blocks/embed.js.map +1 -0
- package/dist/src/render/blocks/faq.d.ts +4 -0
- package/dist/src/render/blocks/faq.d.ts.map +1 -0
- package/dist/src/render/blocks/faq.js +18 -0
- package/dist/src/render/blocks/faq.js.map +1 -0
- package/dist/src/render/blocks/feature-grid.d.ts +4 -0
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
- package/dist/src/render/blocks/feature-grid.js +27 -0
- package/dist/src/render/blocks/feature-grid.js.map +1 -0
- package/dist/src/render/blocks/gallery.d.ts +13 -0
- package/dist/src/render/blocks/gallery.d.ts.map +1 -0
- package/dist/src/render/blocks/gallery.js +26 -0
- package/dist/src/render/blocks/gallery.js.map +1 -0
- package/dist/src/render/blocks/hero.d.ts +11 -0
- package/dist/src/render/blocks/hero.d.ts.map +1 -0
- package/dist/src/render/blocks/hero.js +26 -0
- package/dist/src/render/blocks/hero.js.map +1 -0
- package/dist/src/render/blocks/logo-strip.d.ts +4 -0
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
- package/dist/src/render/blocks/logo-strip.js +20 -0
- package/dist/src/render/blocks/logo-strip.js.map +1 -0
- package/dist/src/render/blocks/logos.d.ts +4 -0
- package/dist/src/render/blocks/logos.d.ts.map +1 -0
- package/dist/src/render/blocks/logos.js +27 -0
- package/dist/src/render/blocks/logos.js.map +1 -0
- package/dist/src/render/blocks/media-figure.d.ts +15 -0
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
- package/dist/src/render/blocks/media-figure.js +32 -0
- package/dist/src/render/blocks/media-figure.js.map +1 -0
- package/dist/src/render/blocks/pricing-table.d.ts +4 -0
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
- package/dist/src/render/blocks/pricing-table.js +32 -0
- package/dist/src/render/blocks/pricing-table.js.map +1 -0
- package/dist/src/render/blocks/prose.d.ts +16 -0
- package/dist/src/render/blocks/prose.d.ts.map +1 -0
- package/dist/src/render/blocks/prose.js +17 -0
- package/dist/src/render/blocks/prose.js.map +1 -0
- package/dist/src/render/blocks/quote.d.ts +16 -0
- package/dist/src/render/blocks/quote.d.ts.map +1 -0
- package/dist/src/render/blocks/quote.js +29 -0
- package/dist/src/render/blocks/quote.js.map +1 -0
- package/dist/src/render/blocks/stat-counter.d.ts +4 -0
- package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
- package/dist/src/render/blocks/stat-counter.js +17 -0
- package/dist/src/render/blocks/stat-counter.js.map +1 -0
- package/dist/src/render/blocks/stats.d.ts +4 -0
- package/dist/src/render/blocks/stats.d.ts.map +1 -0
- package/dist/src/render/blocks/stats.js +19 -0
- package/dist/src/render/blocks/stats.js.map +1 -0
- package/dist/src/render/blocks/testimonial.d.ts +19 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +28 -0
- package/dist/src/render/blocks/testimonial.js.map +1 -0
- package/dist/src/render/chrome.d.ts +3 -0
- package/dist/src/render/chrome.d.ts.map +1 -0
- package/dist/src/render/chrome.js +136 -0
- package/dist/src/render/chrome.js.map +1 -0
- package/dist/src/render/render-block.d.ts +21 -0
- package/dist/src/render/render-block.d.ts.map +1 -0
- package/dist/src/render/render-block.js +104 -0
- package/dist/src/render/render-block.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +13 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +35 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/theme.config.d.ts +12 -0
- package/dist/theme.config.d.ts.map +1 -0
- package/dist/theme.config.js +42 -0
- package/dist/theme.config.js.map +1 -0
- package/package.json +57 -0
- package/src/index.ts +20 -0
- package/src/render/blocks/accordion.ts +51 -0
- package/src/render/blocks/collection-list.ts +286 -0
- package/src/render/blocks/cta.ts +33 -0
- package/src/render/blocks/embed.ts +99 -0
- package/src/render/blocks/faq.ts +58 -0
- package/src/render/blocks/feature-grid.ts +61 -0
- package/src/render/blocks/gallery.ts +46 -0
- package/src/render/blocks/hero.ts +54 -0
- package/src/render/blocks/logo-strip.ts +35 -0
- package/src/render/blocks/logos.ts +57 -0
- package/src/render/blocks/media-figure.ts +43 -0
- package/src/render/blocks/pricing-table.ts +76 -0
- package/src/render/blocks/prose.ts +22 -0
- package/src/render/blocks/quote.ts +46 -0
- package/src/render/blocks/stat-counter.ts +39 -0
- package/src/render/blocks/stats.ts +46 -0
- package/src/render/blocks/testimonial.ts +44 -0
- package/src/render/chrome.ts +162 -0
- package/src/render/render-block.ts +137 -0
- package/src/render/term-archive.ts +87 -0
- package/src/styles/archive.css +40 -0
- package/src/styles/base.css +1037 -0
- package/src/styles/blocks.css +1335 -0
- package/src/styles/theme.css +18 -0
- package/src/styles/tokens.css +308 -0
- package/tokens.json +36 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `blocks@2.0` (RFC 0001). The lighter-weight "as used by" strip next to
|
|
6
|
+
* `logos`'s own press row (`cg-press`, which titles itself and links each
|
|
7
|
+
* mark to its organisation): `logoStrip` has no per-logo name or url, so it
|
|
8
|
+
* carries no title and no links — a plain band of marks with an optional
|
|
9
|
+
* caption set underneath, the way a colophon credits its supporters along
|
|
10
|
+
* the bottom of a masthead page. Each image's own alt text (set once, in
|
|
11
|
+
* the media library) is what names it, exactly as `logos` already falls
|
|
12
|
+
* back to when a link is absent.
|
|
13
|
+
*/
|
|
14
|
+
function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
|
|
15
|
+
return h(
|
|
16
|
+
'li',
|
|
17
|
+
{ class: 'cg-imprint__item' },
|
|
18
|
+
image(ctx, item.media, { className: 'cg-imprint__image', variant: { fit: 'contain' } }),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
|
|
23
|
+
return h(
|
|
24
|
+
'figure',
|
|
25
|
+
{ class: 'cg-block cg-imprint', 'data-block': 'logoStrip' },
|
|
26
|
+
h(
|
|
27
|
+
'ul',
|
|
28
|
+
{ class: 'cg-imprint__items' },
|
|
29
|
+
block.logos.map((item) => renderItem(item, ctx)),
|
|
30
|
+
),
|
|
31
|
+
block.caption === undefined
|
|
32
|
+
? null
|
|
33
|
+
: h('figcaption', { class: 'cg-imprint__caption', 'data-field': 'caption' }, block.caption),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { LogoItem, LogosBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
image,
|
|
8
|
+
type RenderContext,
|
|
9
|
+
} from '@cogenta/theme-kit'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A press strip — "As seen in" — a single row divided by hairline rules
|
|
13
|
+
* rather than a card grid, the way a masthead credits its syndication
|
|
14
|
+
* partners along one line.
|
|
15
|
+
*
|
|
16
|
+
* The organisation's name is contract B's own accessible name for the link:
|
|
17
|
+
* the logo image carries it as `alt` when the media entity has none, so no
|
|
18
|
+
* visually hidden duplicate text is needed — a link whose only content is an
|
|
19
|
+
* image with `alt` text is already named.
|
|
20
|
+
*/
|
|
21
|
+
function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
|
|
22
|
+
const logo = image(ctx, item.media, {
|
|
23
|
+
className: 'cg-press__image',
|
|
24
|
+
altFrom: item.name,
|
|
25
|
+
variant: { fit: 'contain' },
|
|
26
|
+
})
|
|
27
|
+
return h(
|
|
28
|
+
'li',
|
|
29
|
+
{ class: 'cg-press__item' },
|
|
30
|
+
item.url === undefined
|
|
31
|
+
? logo
|
|
32
|
+
: h(
|
|
33
|
+
'a',
|
|
34
|
+
{ class: 'cg-press__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
|
|
35
|
+
logo,
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
|
|
41
|
+
return h(
|
|
42
|
+
'section',
|
|
43
|
+
{ class: 'cg-block cg-press', 'data-block': 'logos' },
|
|
44
|
+
block.title === undefined
|
|
45
|
+
? null
|
|
46
|
+
: heading(
|
|
47
|
+
blockHeadingTag('logos') ?? 'h2',
|
|
48
|
+
{ class: 'cg-press__title', 'data-field': 'title' },
|
|
49
|
+
block.title,
|
|
50
|
+
),
|
|
51
|
+
h(
|
|
52
|
+
'ul',
|
|
53
|
+
{ class: 'cg-press__items' },
|
|
54
|
+
block.items.map((item) => renderItem(item, ctx)),
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MediaFigureBlock } from '@cogenta/blocks'
|
|
2
|
+
import { aspectRatio, type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A plate, in the print sense: the picture runs large, and the caption sits
|
|
6
|
+
* under a hairline rule set in small caps with the credit line trailing it —
|
|
7
|
+
* the way a photo essay is captioned, rather than a card with a paragraph
|
|
8
|
+
* under it.
|
|
9
|
+
*
|
|
10
|
+
* `<figure>`/`<figcaption>` keep the caption announced as belonging to the
|
|
11
|
+
* image; `align` stays a data attribute (contract B's intent, not a class),
|
|
12
|
+
* so the skin — here, this theme — decides what `start`/`end`/`wide`/`full`
|
|
13
|
+
* mean.
|
|
14
|
+
*/
|
|
15
|
+
export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext): HtmlElement {
|
|
16
|
+
const ratio = aspectRatio(block.ratio)
|
|
17
|
+
const hasCaption = block.caption !== undefined || block.credit !== undefined
|
|
18
|
+
return h(
|
|
19
|
+
'figure',
|
|
20
|
+
{
|
|
21
|
+
class: 'cg-block cg-plate',
|
|
22
|
+
'data-block': 'mediaFigure',
|
|
23
|
+
'data-align': block.align ?? 'center',
|
|
24
|
+
style: ratio === undefined ? undefined : `--cg-ratio:${ratio}`,
|
|
25
|
+
},
|
|
26
|
+
image(ctx, block.media, {
|
|
27
|
+
className: 'cg-plate__media',
|
|
28
|
+
sizes: '(min-width: 60rem) 48rem, 100vw',
|
|
29
|
+
}),
|
|
30
|
+
hasCaption
|
|
31
|
+
? h(
|
|
32
|
+
'figcaption',
|
|
33
|
+
{ class: 'cg-plate__caption' },
|
|
34
|
+
block.caption === undefined
|
|
35
|
+
? null
|
|
36
|
+
: h('span', { class: 'cg-plate__text' }, block.caption),
|
|
37
|
+
block.credit === undefined
|
|
38
|
+
? null
|
|
39
|
+
: h('span', { class: 'cg-plate__credit', 'data-field': 'credit' }, block.credit),
|
|
40
|
+
)
|
|
41
|
+
: null,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { PricingTableBlock, PricingTier } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
actionLink,
|
|
4
|
+
blockHeadingTag,
|
|
5
|
+
type HeadingTag,
|
|
6
|
+
type HtmlElement,
|
|
7
|
+
h,
|
|
8
|
+
heading,
|
|
9
|
+
nestedHeadingTag,
|
|
10
|
+
type RenderContext,
|
|
11
|
+
} from '@cogenta/theme-kit'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `blocks@2.0` (RFC 0001). A classified-ads rate card: tiers set side by
|
|
15
|
+
* side and separated by the theme's one visible column rule (the same
|
|
16
|
+
* device `hero` and `collectionList` use), rather than a set of raised
|
|
17
|
+
* cards — a print page has no elevation to raise a card off of.
|
|
18
|
+
*
|
|
19
|
+
* `highlighted` is an editorial signal, not a colour: it becomes
|
|
20
|
+
* `data-highlighted` for the stylesheet to pick up (an ink-reversed panel,
|
|
21
|
+
* here) and `aria-current` so it is announced, not only shown.
|
|
22
|
+
*/
|
|
23
|
+
function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
24
|
+
return h(
|
|
25
|
+
'li',
|
|
26
|
+
{
|
|
27
|
+
class: 'cg-ratecard__tier',
|
|
28
|
+
'data-highlighted': tier.highlighted === true ? 'true' : undefined,
|
|
29
|
+
'aria-current': tier.highlighted === true ? 'true' : undefined,
|
|
30
|
+
},
|
|
31
|
+
heading(tag, { class: 'cg-ratecard__name' }, tier.name),
|
|
32
|
+
h(
|
|
33
|
+
'p',
|
|
34
|
+
{ class: 'cg-ratecard__price' },
|
|
35
|
+
h('span', { class: 'cg-ratecard__amount' }, tier.price),
|
|
36
|
+
tier.interval === undefined
|
|
37
|
+
? null
|
|
38
|
+
: h('span', { class: 'cg-ratecard__interval' }, tier.interval),
|
|
39
|
+
),
|
|
40
|
+
tier.features.length === 0
|
|
41
|
+
? null
|
|
42
|
+
: h(
|
|
43
|
+
'ul',
|
|
44
|
+
{ class: 'cg-ratecard__features' },
|
|
45
|
+
tier.features.map((feature) => h('li', { class: 'cg-ratecard__feature' }, feature)),
|
|
46
|
+
),
|
|
47
|
+
tier.action === undefined
|
|
48
|
+
? null
|
|
49
|
+
: h(
|
|
50
|
+
'div',
|
|
51
|
+
{ class: 'cg-ratecard__action' },
|
|
52
|
+
actionLink(ctx, { ...tier.action, emphasis: tier.action.emphasis ?? 'primary' }),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
|
|
58
|
+
const hasTitle = block.title !== undefined
|
|
59
|
+
const tierTag = nestedHeadingTag('pricingTable', hasTitle)
|
|
60
|
+
return h(
|
|
61
|
+
'section',
|
|
62
|
+
{ class: 'cg-block cg-ratecard', 'data-block': 'pricingTable' },
|
|
63
|
+
hasTitle
|
|
64
|
+
? heading(
|
|
65
|
+
blockHeadingTag('pricingTable') ?? 'h2',
|
|
66
|
+
{ class: 'cg-ratecard__title', 'data-field': 'title' },
|
|
67
|
+
block.title ?? '',
|
|
68
|
+
)
|
|
69
|
+
: null,
|
|
70
|
+
h(
|
|
71
|
+
'ul',
|
|
72
|
+
{ class: 'cg-ratecard__tiers' },
|
|
73
|
+
block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProseBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, type RenderContext, renderRichText } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `prose` declares `headingLevel: 'none'` — it contributes no heading of its
|
|
6
|
+
* own, and `renderRichText` (shared, `@cogenta/theme-kit`) is what actually
|
|
7
|
+
* turns the document into markup, including its embedded figures
|
|
8
|
+
* (`.cg-prose__figure`, a class name that module already fixes — this file's
|
|
9
|
+
* own container class has to match it).
|
|
10
|
+
*
|
|
11
|
+
* The drop cap and the pull-quote treatment on a nested `<blockquote>` are
|
|
12
|
+
* both pure CSS, keyed off `.cg-prose > :first-child` and `.cg-prose
|
|
13
|
+
* blockquote` in `blocks.css` — nothing here has to know which paragraph
|
|
14
|
+
* came first.
|
|
15
|
+
*/
|
|
16
|
+
export function renderProse(block: ProseBlock, ctx: RenderContext): HtmlElement {
|
|
17
|
+
return h(
|
|
18
|
+
'div',
|
|
19
|
+
{ class: 'cg-block cg-prose', 'data-block': 'prose' },
|
|
20
|
+
renderRichText(ctx, block.body),
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { QuoteBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The pull quote — the one typographic gesture a magazine spread always
|
|
6
|
+
* makes. Set large in the display serif, indented from a heavy left rule
|
|
7
|
+
* rather than centred under a giant glyph, with the attribution trailing it
|
|
8
|
+
* on its own line, joined by a typographic em-rule instead of a comma the
|
|
9
|
+
* markup does not carry.
|
|
10
|
+
*
|
|
11
|
+
* `<figure><blockquote>…</blockquote><figcaption>` is the attribution
|
|
12
|
+
* pattern the HTML spec itself prescribes — the author's name sitting
|
|
13
|
+
* outside the `<blockquote>` is what stops it from being claimed as part of
|
|
14
|
+
* what was said.
|
|
15
|
+
*/
|
|
16
|
+
export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement {
|
|
17
|
+
const hasAttribution =
|
|
18
|
+
block.author !== undefined || block.role !== undefined || block.avatar !== undefined
|
|
19
|
+
return h(
|
|
20
|
+
'figure',
|
|
21
|
+
{ class: 'cg-block cg-pullquote', 'data-block': 'quote' },
|
|
22
|
+
h('blockquote', { class: 'cg-pullquote__text' }, h('p', { 'data-field': 'text' }, block.text)),
|
|
23
|
+
hasAttribution
|
|
24
|
+
? h(
|
|
25
|
+
'figcaption',
|
|
26
|
+
{ class: 'cg-pullquote__attribution' },
|
|
27
|
+
block.avatar === undefined
|
|
28
|
+
? null
|
|
29
|
+
: image(ctx, block.avatar, {
|
|
30
|
+
className: 'cg-pullquote__avatar',
|
|
31
|
+
variant: { width: 96, height: 96, fit: 'cover' },
|
|
32
|
+
}),
|
|
33
|
+
h(
|
|
34
|
+
'span',
|
|
35
|
+
{ class: 'cg-pullquote__names' },
|
|
36
|
+
block.author === undefined
|
|
37
|
+
? null
|
|
38
|
+
: h('span', { class: 'cg-pullquote__author', 'data-field': 'author' }, block.author),
|
|
39
|
+
block.role === undefined
|
|
40
|
+
? null
|
|
41
|
+
: h('span', { class: 'cg-pullquote__role', 'data-field': 'role' }, block.role),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
: null,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { StatCounterBlock, StatCounterItem } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
type RenderContext,
|
|
8
|
+
} from '@cogenta/theme-kit'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `blocks@2.0` (RFC 0001). A circulation tally, set apart from `stats`'s own
|
|
12
|
+
* `cg-figures` (which carries vertical column rules and an optional `unit`):
|
|
13
|
+
* `statCounter` has no `unit`, so it is laid out as a single running line —
|
|
14
|
+
* each figure and its label separated only by a shared baseline rule, the
|
|
15
|
+
* way a masthead prints its circulation and edition counts along one strip.
|
|
16
|
+
*/
|
|
17
|
+
function renderItem(item: StatCounterItem): HtmlElement {
|
|
18
|
+
return h(
|
|
19
|
+
'div',
|
|
20
|
+
{ class: 'cg-tally__item' },
|
|
21
|
+
h('dd', { class: 'cg-tally__value' }, item.value),
|
|
22
|
+
h('dt', { class: 'cg-tally__label' }, item.label),
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
|
|
27
|
+
return h(
|
|
28
|
+
'section',
|
|
29
|
+
{ class: 'cg-block cg-tally', 'data-block': 'statCounter' },
|
|
30
|
+
block.title === undefined
|
|
31
|
+
? null
|
|
32
|
+
: heading(
|
|
33
|
+
blockHeadingTag('statCounter') ?? 'h2',
|
|
34
|
+
{ class: 'cg-tally__title', 'data-field': 'title' },
|
|
35
|
+
block.title,
|
|
36
|
+
),
|
|
37
|
+
h('dl', { class: 'cg-tally__items' }, block.stats.map(renderItem)),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { StatItem, StatsBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
type RenderContext,
|
|
8
|
+
} from '@cogenta/theme-kit'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "By the numbers" — an infographic sidebar with a real newspaper
|
|
12
|
+
* `column-rule` between figures, each set in the display serif with tabular
|
|
13
|
+
* numerals so a column of changing digits never shifts width.
|
|
14
|
+
*
|
|
15
|
+
* A description list, as in the reference theme: each figure *describes* its
|
|
16
|
+
* label, which is exactly what `<dt>`/`<dd>` mean, and it survives being read
|
|
17
|
+
* linearly by anything that ignores the stylesheet.
|
|
18
|
+
*/
|
|
19
|
+
function renderItem(item: StatItem): HtmlElement {
|
|
20
|
+
return h(
|
|
21
|
+
'div',
|
|
22
|
+
{ class: 'cg-figures__item' },
|
|
23
|
+
h('dt', { class: 'cg-figures__label' }, item.label),
|
|
24
|
+
h(
|
|
25
|
+
'dd',
|
|
26
|
+
{ class: 'cg-figures__value' },
|
|
27
|
+
item.value,
|
|
28
|
+
item.unit === undefined ? null : h('span', { class: 'cg-figures__unit' }, item.unit),
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
|
|
34
|
+
return h(
|
|
35
|
+
'section',
|
|
36
|
+
{ class: 'cg-block cg-figures', 'data-block': 'stats' },
|
|
37
|
+
block.title === undefined
|
|
38
|
+
? null
|
|
39
|
+
: heading(
|
|
40
|
+
blockHeadingTag('stats') ?? 'h2',
|
|
41
|
+
{ class: 'cg-figures__title', 'data-field': 'title' },
|
|
42
|
+
block.title,
|
|
43
|
+
),
|
|
44
|
+
h('dl', { class: 'cg-figures__items' }, block.items.map(renderItem)),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { TestimonialBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext, renderRichText } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `blocks@2.0` (RFC 0001). A reader's letter, not a pull quote: `quote`
|
|
6
|
+
* already owns the theme's one "loud" typographic gesture (the giant quote
|
|
7
|
+
* mark and heavy rule in `cg-pullquote`), so a testimonial reads instead as
|
|
8
|
+
* a boxed letter-to-the-editor — a framed panel, the quote set in italic
|
|
9
|
+
* serif at body size rather than blown up, and a byline row underneath a
|
|
10
|
+
* hairline rule, the way a magazine runs its reader mail column.
|
|
11
|
+
*
|
|
12
|
+
* The quote is rich text (unlike `quote`'s plain-text `text`), so a
|
|
13
|
+
* testimonial can carry a link or emphasis; `renderRichText` handles it the
|
|
14
|
+
* same way `accordion`'s answers and `prose`'s body do.
|
|
15
|
+
*
|
|
16
|
+
* The avatar is decorative, exactly like `quote`'s own: the name sits right
|
|
17
|
+
* beside it in text, so its media entity's alt text is expected to be empty.
|
|
18
|
+
*/
|
|
19
|
+
export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
|
|
20
|
+
const { attribution } = block
|
|
21
|
+
return h(
|
|
22
|
+
'figure',
|
|
23
|
+
{ class: 'cg-block cg-letter', 'data-block': 'testimonial' },
|
|
24
|
+
h('blockquote', { class: 'cg-letter__quote' }, renderRichText(ctx, block.quote)),
|
|
25
|
+
h(
|
|
26
|
+
'figcaption',
|
|
27
|
+
{ class: 'cg-letter__byline' },
|
|
28
|
+
attribution.avatar === undefined
|
|
29
|
+
? null
|
|
30
|
+
: image(ctx, attribution.avatar, {
|
|
31
|
+
className: 'cg-letter__avatar',
|
|
32
|
+
variant: { width: 96, height: 96, fit: 'cover' },
|
|
33
|
+
}),
|
|
34
|
+
h(
|
|
35
|
+
'span',
|
|
36
|
+
{ class: 'cg-letter__names' },
|
|
37
|
+
h('span', { class: 'cg-letter__name' }, attribution.name),
|
|
38
|
+
attribution.role === undefined
|
|
39
|
+
? null
|
|
40
|
+
: h('span', { class: 'cg-letter__role' }, attribution.role),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChromeInput,
|
|
3
|
+
type ChromeNavLink,
|
|
4
|
+
type ChromeResult,
|
|
5
|
+
escapeAttribute,
|
|
6
|
+
escapeText,
|
|
7
|
+
renderBrandMark,
|
|
8
|
+
renderSocialLinks,
|
|
9
|
+
renderThemeToggle,
|
|
10
|
+
serialize,
|
|
11
|
+
} from '@cogenta/theme-kit'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The masthead. A magazine's front matter is a nameplate — the title set in
|
|
15
|
+
* the display serif, centred, with a hairline rule above and below — and a
|
|
16
|
+
* quiet rubric bar underneath it, the way a print contents strip reads. That
|
|
17
|
+
* is a structurally different header from a left-aligned wordmark plus a
|
|
18
|
+
* right-aligned menu (`theme-canonical`'s own layout): the nameplate gets
|
|
19
|
+
* its own row so it can be set large without competing with the nav for
|
|
20
|
+
* width, and the nav becomes a second, quieter row.
|
|
21
|
+
*
|
|
22
|
+
* L25 pro pass (`theme@1.4`): the top strip now names today's date rather
|
|
23
|
+
* than a static tagline (`Intl.DateTimeFormat(ctx.locale, { dateStyle:
|
|
24
|
+
* 'full' })` on `new Date()` — legitimate here because every page renders
|
|
25
|
+
* per request, `cogenta serve` never caches a rendered page across a day
|
|
26
|
+
* boundary for an anonymous visitor beyond `security.pageMaxAge`, and this
|
|
27
|
+
* theme's own masthead has never claimed to be static-generated — `theme.
|
|
28
|
+
* config.ts` still declares `runtime: 'static'`, which describes what this
|
|
29
|
+
* theme *needs* to render a page, not a promise that today's date is frozen
|
|
30
|
+
* at build time); the rubric row now also carries `headerAction` as a filled
|
|
31
|
+
* "Subscribe"-style button; and a CSS-only `<details>` disclosure collapses
|
|
32
|
+
* the whole rubric row into a hamburger below the nameplate's own
|
|
33
|
+
* breakpoint, exactly the technique `theme-blog`'s masthead already proved
|
|
34
|
+
* against a real browser (a closed `<details>`'s non-summary content cannot
|
|
35
|
+
* be forced open by CSS alone, so the collapsed and expanded nav are two
|
|
36
|
+
* separate `<nav>`s, never one pushed into a state it was never opened
|
|
37
|
+
* into — see that theme's own `chrome.ts` for the verified detail).
|
|
38
|
+
*
|
|
39
|
+
* L26 D4 — the manual light/dark/system toggle (`renderThemeToggle`,
|
|
40
|
+
* `@cogenta/theme-kit`) sits in the top strip, next to today's date, not in
|
|
41
|
+
* the rubric row below: the top strip renders unconditionally on every
|
|
42
|
+
* page, while the rubric (`hasRubric`) only renders when there is a nav or
|
|
43
|
+
* a header action — a toggle placed there would vanish on a site with
|
|
44
|
+
* neither, which is exactly the "dark mode looks unsupported" bug this
|
|
45
|
+
* feature exists to fix.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
function renderNavItems(links: readonly ChromeNavLink[], className: string): string {
|
|
49
|
+
const items = links
|
|
50
|
+
.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
|
|
51
|
+
.map((link) => {
|
|
52
|
+
const label = escapeText(link.label)
|
|
53
|
+
const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
|
|
54
|
+
if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
|
|
55
|
+
const href = escapeAttribute(link.href)
|
|
56
|
+
const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
|
|
57
|
+
return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
|
|
58
|
+
})
|
|
59
|
+
.join('')
|
|
60
|
+
return items === '' ? '' : `<ul class="${className}">${items}</ul>`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The rubric row's own call-to-action link (`theme@1.4`) — a filled button, the masthead's one place of solid colour. */
|
|
64
|
+
function renderHeaderAction(action: ChromeInput['headerAction']): string {
|
|
65
|
+
if (action === undefined) return ''
|
|
66
|
+
return (
|
|
67
|
+
`<a class="cg-action cg-masthead__action" data-emphasis="primary" ` +
|
|
68
|
+
`href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A hamburger mark drawn from three stacked bars — no icon font, no glyph a font might not ship. */
|
|
73
|
+
function menuGlyph(): string {
|
|
74
|
+
return '<span class="cg-masthead__bars" aria-hidden="true"></span>'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function todaysDate(locale: string): string {
|
|
78
|
+
try {
|
|
79
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'full' }).format(new Date())
|
|
80
|
+
} catch {
|
|
81
|
+
return new Date().toISOString().slice(0, 10)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function renderChrome(input: ChromeInput): ChromeResult {
|
|
86
|
+
const siteNameText = escapeText(input.site.name)
|
|
87
|
+
const navItems = renderNavItems(input.headerNav, 'cg-masthead__menu')
|
|
88
|
+
const headerAction = renderHeaderAction(input.headerAction)
|
|
89
|
+
const footerNav = renderNavItems(input.footerNav, 'cg-colophon__menu')
|
|
90
|
+
const homeHref = escapeAttribute(input.homeHref)
|
|
91
|
+
// A nameplate is the one place a masthead earns a logo: it replaces the
|
|
92
|
+
// wordmark on its own row. The colophon keeps the name in text, so the
|
|
93
|
+
// site is still named in a page whose images never load.
|
|
94
|
+
const nameplate = renderBrandMark(input.brand, { className: 'cg-masthead__logo' }) ?? siteNameText
|
|
95
|
+
const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
|
|
96
|
+
|
|
97
|
+
const hasRubric = navItems !== '' || headerAction !== ''
|
|
98
|
+
// Two renderings of the same rubric row, never both visible at once (CSS,
|
|
99
|
+
// `min-width: 56rem`) — see the file-level comment for why this cannot be
|
|
100
|
+
// a single `<details open>` forced open at width.
|
|
101
|
+
const mobileRubric = !hasRubric
|
|
102
|
+
? ''
|
|
103
|
+
: `<details class="cg-masthead__disclosure">` +
|
|
104
|
+
`<summary class="cg-masthead__toggle" aria-label="Menu">${menuGlyph()}</summary>` +
|
|
105
|
+
`<nav class="cg-masthead__panel" aria-label="Primary">${navItems}${headerAction}</nav>` +
|
|
106
|
+
`</details>`
|
|
107
|
+
const desktopRubric = !hasRubric
|
|
108
|
+
? ''
|
|
109
|
+
: `<nav class="cg-masthead__nav" aria-label="Primary"><div class="cg-masthead__nav-inner">${navItems}${headerAction}</div></nav>`
|
|
110
|
+
|
|
111
|
+
const header =
|
|
112
|
+
`<header class="cg-masthead">` +
|
|
113
|
+
`<div class="cg-masthead__top">` +
|
|
114
|
+
`<span class="cg-masthead__date">${escapeText(todaysDate(input.locale))}</span>` +
|
|
115
|
+
`${themeToggle}` +
|
|
116
|
+
`</div>` +
|
|
117
|
+
`<div class="cg-masthead__nameplate">` +
|
|
118
|
+
`<a class="cg-masthead__wordmark" href="${homeHref}">${nameplate}</a>` +
|
|
119
|
+
`${mobileRubric}` +
|
|
120
|
+
`</div>` +
|
|
121
|
+
`${desktopRubric}` +
|
|
122
|
+
`</header>`
|
|
123
|
+
|
|
124
|
+
const tagline =
|
|
125
|
+
input.tagline === undefined
|
|
126
|
+
? ''
|
|
127
|
+
: `<p class="cg-colophon__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
|
|
128
|
+
const social =
|
|
129
|
+
input.social === undefined
|
|
130
|
+
? ''
|
|
131
|
+
: serialize(
|
|
132
|
+
renderSocialLinks(input.social, {
|
|
133
|
+
className: 'cg-colophon__social',
|
|
134
|
+
itemClassName: 'cg-colophon__social-item',
|
|
135
|
+
}) ?? { kind: 'text', value: '' },
|
|
136
|
+
)
|
|
137
|
+
const footerNote =
|
|
138
|
+
input.footerNote === undefined
|
|
139
|
+
? ''
|
|
140
|
+
: `<p class="cg-colophon__note">${escapeText(input.footerNote)}</p>`
|
|
141
|
+
|
|
142
|
+
const footer =
|
|
143
|
+
`<footer class="cg-colophon"><div class="cg-colophon__grid">` +
|
|
144
|
+
`<div class="cg-colophon__about">` +
|
|
145
|
+
`<a class="cg-colophon__wordmark" href="${homeHref}">${siteNameText}</a>` +
|
|
146
|
+
`${tagline}</div>` +
|
|
147
|
+
`<div class="cg-colophon__sections">` +
|
|
148
|
+
`<p class="cg-colophon__heading">Sections</p>` +
|
|
149
|
+
`${footerNav === '' ? '' : `<nav aria-label="Footer">${footerNav}</nav>`}` +
|
|
150
|
+
`</div>` +
|
|
151
|
+
`<div class="cg-colophon__follow">` +
|
|
152
|
+
`<p class="cg-colophon__heading">Follow</p>` +
|
|
153
|
+
`${social}` +
|
|
154
|
+
`</div>` +
|
|
155
|
+
`<div class="cg-colophon__meta">` +
|
|
156
|
+
`${footerNote}` +
|
|
157
|
+
`<div class="cg-colophon__branding">${input.brandingHtml}</div>` +
|
|
158
|
+
`</div>` +
|
|
159
|
+
`</div></footer>`
|
|
160
|
+
|
|
161
|
+
return { header, footer }
|
|
162
|
+
}
|