@cogenta/theme-canonical 0.2.0 → 1.1.0
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 +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/render/actions.d.ts +2 -16
- package/dist/src/render/actions.d.ts.map +1 -1
- package/dist/src/render/actions.js +2 -42
- package/dist/src/render/actions.js.map +1 -1
- package/dist/src/render/blocks/accordion.d.ts +5 -0
- package/dist/src/render/blocks/accordion.d.ts.map +1 -0
- package/dist/src/render/blocks/accordion.js +19 -0
- package/dist/src/render/blocks/accordion.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +9 -7
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
- package/dist/src/render/blocks/collection-list.js +8 -14
- package/dist/src/render/blocks/collection-list.js.map +1 -1
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
- package/dist/src/render/blocks/feature-grid.js +7 -2
- package/dist/src/render/blocks/feature-grid.js.map +1 -1
- package/dist/src/render/blocks/logo-strip.d.ts +5 -0
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
- package/dist/src/render/blocks/logo-strip.js +18 -0
- package/dist/src/render/blocks/logo-strip.js.map +1 -0
- package/dist/src/render/blocks/pricing-table.d.ts +5 -0
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
- package/dist/src/render/blocks/pricing-table.js +29 -0
- package/dist/src/render/blocks/pricing-table.js.map +1 -0
- package/dist/src/render/blocks/stat-counter.d.ts +5 -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/testimonial.d.ts +14 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +24 -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 +82 -0
- package/dist/src/render/chrome.js.map +1 -0
- package/dist/src/render/comments.d.ts +3 -0
- package/dist/src/render/comments.d.ts.map +1 -0
- package/dist/src/render/comments.js +3 -0
- package/dist/src/render/comments.js.map +1 -0
- package/dist/src/render/entry.d.ts +2 -6
- package/dist/src/render/entry.d.ts.map +1 -1
- package/dist/src/render/entry.js +2 -34
- package/dist/src/render/entry.js.map +1 -1
- package/dist/src/render/heading.d.ts +2 -13
- package/dist/src/render/heading.d.ts.map +1 -1
- package/dist/src/render/heading.js +2 -33
- package/dist/src/render/heading.js.map +1 -1
- package/dist/src/render/html.d.ts +2 -43
- package/dist/src/render/html.d.ts.map +1 -1
- package/dist/src/render/html.js +2 -95
- package/dist/src/render/html.js.map +1 -1
- package/dist/src/render/icons.d.ts +3 -0
- package/dist/src/render/icons.d.ts.map +1 -0
- package/dist/src/render/icons.js +3 -0
- package/dist/src/render/icons.js.map +1 -0
- package/dist/src/render/media.d.ts +2 -31
- package/dist/src/render/media.d.ts.map +1 -1
- package/dist/src/render/media.js +2 -41
- package/dist/src/render/media.js.map +1 -1
- package/dist/src/render/render-block.d.ts +11 -22
- package/dist/src/render/render-block.d.ts.map +1 -1
- package/dist/src/render/render-block.js +63 -62
- package/dist/src/render/render-block.js.map +1 -1
- package/dist/src/render/rich-text.d.ts +2 -4
- package/dist/src/render/rich-text.d.ts.map +1 -1
- package/dist/src/render/rich-text.js +2 -104
- package/dist/src/render/rich-text.js.map +1 -1
- package/dist/src/render/term-archive.d.ts +12 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +39 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/src/theme-contract.d.ts +10 -162
- package/dist/src/theme-contract.d.ts.map +1 -1
- package/dist/src/theme-contract.js +9 -17
- package/dist/src/theme-contract.js.map +1 -1
- package/dist/theme.config.d.ts +5 -16
- package/dist/theme.config.d.ts.map +1 -1
- package/dist/theme.config.js +16 -5
- package/dist/theme.config.js.map +1 -1
- package/package.json +4 -2
- package/src/index.ts +4 -0
- package/src/render/actions.ts +2 -57
- package/src/render/blocks/accordion.ts +44 -0
- package/src/render/blocks/collection-list.ts +9 -15
- package/src/render/blocks/feature-grid.ts +11 -2
- package/src/render/blocks/logo-strip.ts +38 -0
- package/src/render/blocks/pricing-table.ts +65 -0
- package/src/render/blocks/stat-counter.ts +34 -0
- package/src/render/blocks/testimonial.ts +41 -0
- package/src/render/chrome.ts +105 -0
- package/src/render/comments.ts +6 -0
- package/src/render/entry.ts +2 -40
- package/src/render/heading.ts +7 -44
- package/src/render/html.ts +15 -122
- package/src/render/icons.ts +2 -0
- package/src/render/media.ts +2 -62
- package/src/render/render-block.ts +85 -75
- package/src/render/rich-text.ts +2 -150
- package/src/render/term-archive.ts +85 -0
- package/src/styles/archive.css +40 -0
- package/src/styles/base.css +525 -3
- package/src/styles/blocks.css +344 -14
- package/src/styles/theme.css +1 -0
- package/src/theme-contract.ts +23 -155
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AccordionBlock, AccordionItem } from '@cogenta/blocks'
|
|
2
|
+
import type { RenderContext } from '../../theme-contract.js'
|
|
3
|
+
import { blockHeadingTag, heading } from '../heading.js'
|
|
4
|
+
import { type HtmlElement, h } from '../html.js'
|
|
5
|
+
import { renderRichText } from '../rich-text.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `blocks@2.0` (RFC 0001). Shares `faq`'s `<details>`/`<summary>` mechanics —
|
|
9
|
+
* expanding, keyboard operation and the expanded state all come from the
|
|
10
|
+
* browser, at zero bytes of JavaScript — but is its own block, since
|
|
11
|
+
* "accordion" and "frequently asked question" are different editorial
|
|
12
|
+
* intents even when the underlying question/answer shape coincides.
|
|
13
|
+
*/
|
|
14
|
+
function renderItem(item: AccordionItem, ctx: RenderContext): HtmlElement {
|
|
15
|
+
return h(
|
|
16
|
+
'li',
|
|
17
|
+
{ class: 'cg-accordion__item' },
|
|
18
|
+
h(
|
|
19
|
+
'details',
|
|
20
|
+
{ class: 'cg-accordion__details' },
|
|
21
|
+
h('summary', { class: 'cg-accordion__question' }, item.question),
|
|
22
|
+
h('div', { class: 'cg-accordion__answer' }, renderRichText(ctx, item.answer)),
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderAccordion(block: AccordionBlock, ctx: RenderContext): HtmlElement {
|
|
28
|
+
return h(
|
|
29
|
+
'section',
|
|
30
|
+
{ class: 'cg-block cg-accordion', 'data-block': 'accordion' },
|
|
31
|
+
block.title === undefined
|
|
32
|
+
? null
|
|
33
|
+
: heading(
|
|
34
|
+
blockHeadingTag('accordion') ?? 'h2',
|
|
35
|
+
{ class: 'cg-accordion__title', 'data-field': 'title' },
|
|
36
|
+
block.title,
|
|
37
|
+
),
|
|
38
|
+
h(
|
|
39
|
+
'ul',
|
|
40
|
+
{ class: 'cg-accordion__items' },
|
|
41
|
+
block.items.map((item) => renderItem(item, ctx)),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CollectionListBlock } from '@cogenta/blocks'
|
|
2
|
-
import type { ContentEntry,
|
|
2
|
+
import type { ContentEntry, RenderContext } from '../../theme-contract.js'
|
|
3
3
|
import { entryDate, entryExcerpt, entryHref, entryTitle } from '../entry.js'
|
|
4
4
|
import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
|
|
5
5
|
import { type HtmlElement, h } from '../html.js'
|
|
@@ -8,21 +8,15 @@ import { type HtmlElement, h } from '../html.js'
|
|
|
8
8
|
* The only block of the twelve that reads data at render time — contract B
|
|
9
9
|
* marks it `runtime: 'server'` for that reason.
|
|
10
10
|
*
|
|
11
|
-
* The read is *not* done here. `query` builds the request
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* The read is *not* done here. `query` builds the request (`@cogenta/theme-kit`'s
|
|
12
|
+
* `buildCollectionListQuery`, shared across every theme since it derives
|
|
13
|
+
* purely from the block's own fields), the caller awaits `ctx.content.list(...)`
|
|
14
|
+
* before rendering starts, and this function stays a pure function of the
|
|
15
|
+
* entries it is handed. That keeps the markup snapshot-testable and keeps a
|
|
16
|
+
* theme's single door to data — the read-only content client — in one visible
|
|
17
|
+
* place.
|
|
16
18
|
*/
|
|
17
|
-
export
|
|
18
|
-
return {
|
|
19
|
-
collection: block.collection,
|
|
20
|
-
...(block.filter === undefined ? {} : { filter: block.filter }),
|
|
21
|
-
...(block.sort === undefined ? {} : { sort: block.sort }),
|
|
22
|
-
// Capped by contract B at 100; an absent limit still must not mean "all".
|
|
23
|
-
limit: block.limit ?? 10,
|
|
24
|
-
}
|
|
25
|
-
}
|
|
19
|
+
export { buildCollectionListQuery as query } from '@cogenta/theme-kit'
|
|
26
20
|
|
|
27
21
|
function renderEntry(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
28
22
|
const date = entryDate(entry)
|
|
@@ -3,6 +3,7 @@ import type { RenderContext } from '../../theme-contract.js'
|
|
|
3
3
|
import { href } from '../actions.js'
|
|
4
4
|
import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
|
|
5
5
|
import { type HtmlElement, h } from '../html.js'
|
|
6
|
+
import { renderIcon } from '../icons.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The item's title is the link, so the link's accessible name is the feature's
|
|
@@ -10,7 +11,11 @@ import { type HtmlElement, h } from '../html.js'
|
|
|
10
11
|
* WCAG 2.4.4, and the block carries no label field to write one anyway.
|
|
11
12
|
*
|
|
12
13
|
* `icon` names a symbol, never markup: it is exposed as a data attribute for
|
|
13
|
-
* the skin and
|
|
14
|
+
* the skin (and, since L25, the page builder — see `data-icon` below) and,
|
|
15
|
+
* when the name is one `renderIcon` (`@cogenta/theme-kit`) recognises, a real
|
|
16
|
+
* inline glyph — `aria-hidden`, since the title already names the feature.
|
|
17
|
+
* An unrecognised name keeps the pre-L25 behaviour: the bare, empty
|
|
18
|
+
* `data-icon` span, styled by `.cg-feature__icon` alone.
|
|
14
19
|
*/
|
|
15
20
|
function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
16
21
|
const title =
|
|
@@ -26,7 +31,11 @@ function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): Htm
|
|
|
26
31
|
{ class: 'cg-feature' },
|
|
27
32
|
item.icon === undefined
|
|
28
33
|
? null
|
|
29
|
-
: h(
|
|
34
|
+
: h(
|
|
35
|
+
'span',
|
|
36
|
+
{ class: 'cg-feature__icon', 'data-icon': item.icon, 'aria-hidden': 'true' },
|
|
37
|
+
renderIcon(item.icon),
|
|
38
|
+
),
|
|
30
39
|
title,
|
|
31
40
|
item.text === undefined ? null : h('p', { class: 'cg-feature__text' }, item.text),
|
|
32
41
|
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
|
|
2
|
+
import type { RenderContext } from '../../theme-contract.js'
|
|
3
|
+
import { type HtmlElement, h } from '../html.js'
|
|
4
|
+
import { image } from '../media.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `blocks@2.0` (RFC 0001). The lighter-weight "as seen in" / social-proof
|
|
8
|
+
* row next to `logos`: no per-logo name or link, so no accessible-name field
|
|
9
|
+
* to require — each image's own alt text (set once, in the media library)
|
|
10
|
+
* is what names it, exactly as `logos` already falls back to when a link is
|
|
11
|
+
* absent.
|
|
12
|
+
*/
|
|
13
|
+
function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
|
|
14
|
+
return h(
|
|
15
|
+
'li',
|
|
16
|
+
{ class: 'cg-logo-strip__item' },
|
|
17
|
+
image(ctx, item.media, { className: 'cg-logo-strip__image', variant: { fit: 'contain' } }),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
|
|
22
|
+
return h(
|
|
23
|
+
'figure',
|
|
24
|
+
{ class: 'cg-block cg-logo-strip', 'data-block': 'logoStrip' },
|
|
25
|
+
h(
|
|
26
|
+
'ul',
|
|
27
|
+
{ class: 'cg-logo-strip__items' },
|
|
28
|
+
block.logos.map((item) => renderItem(item, ctx)),
|
|
29
|
+
),
|
|
30
|
+
block.caption === undefined
|
|
31
|
+
? null
|
|
32
|
+
: h(
|
|
33
|
+
'figcaption',
|
|
34
|
+
{ class: 'cg-logo-strip__caption', 'data-field': 'caption' },
|
|
35
|
+
block.caption,
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { PricingTableBlock, PricingTier } from '@cogenta/blocks'
|
|
2
|
+
import type { RenderContext } from '../../theme-contract.js'
|
|
3
|
+
import { actionLink } from '../actions.js'
|
|
4
|
+
import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
|
|
5
|
+
import { type HtmlElement, h } from '../html.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `blocks@2.0` (RFC 0001). `highlighted` is editorial emphasis, not a colour:
|
|
9
|
+
* it becomes `data-highlighted` for the skin to style, and `aria-current`
|
|
10
|
+
* (loosely "the one on offer") so it is announced, not only shown.
|
|
11
|
+
*/
|
|
12
|
+
function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
13
|
+
return h(
|
|
14
|
+
'li',
|
|
15
|
+
{
|
|
16
|
+
class: 'cg-pricing__tier',
|
|
17
|
+
'data-highlighted': tier.highlighted === true ? 'true' : undefined,
|
|
18
|
+
'aria-current': tier.highlighted === true ? 'true' : undefined,
|
|
19
|
+
},
|
|
20
|
+
heading(tag, { class: 'cg-pricing__name' }, tier.name),
|
|
21
|
+
h(
|
|
22
|
+
'p',
|
|
23
|
+
{ class: 'cg-pricing__price' },
|
|
24
|
+
h('span', { class: 'cg-pricing__amount' }, tier.price),
|
|
25
|
+
tier.interval === undefined
|
|
26
|
+
? null
|
|
27
|
+
: h('span', { class: 'cg-pricing__interval' }, tier.interval),
|
|
28
|
+
),
|
|
29
|
+
tier.features.length === 0
|
|
30
|
+
? null
|
|
31
|
+
: h(
|
|
32
|
+
'ul',
|
|
33
|
+
{ class: 'cg-pricing__features' },
|
|
34
|
+
tier.features.map((feature) => h('li', {}, feature)),
|
|
35
|
+
),
|
|
36
|
+
tier.action === undefined
|
|
37
|
+
? null
|
|
38
|
+
: h(
|
|
39
|
+
'div',
|
|
40
|
+
{ class: 'cg-pricing__action' },
|
|
41
|
+
actionLink(ctx, { ...tier.action, emphasis: tier.action.emphasis ?? 'primary' }),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
|
|
47
|
+
const hasTitle = block.title !== undefined
|
|
48
|
+
const tierTag = nestedHeadingTag('pricingTable', hasTitle)
|
|
49
|
+
return h(
|
|
50
|
+
'section',
|
|
51
|
+
{ class: 'cg-block cg-pricing', 'data-block': 'pricingTable' },
|
|
52
|
+
hasTitle
|
|
53
|
+
? heading(
|
|
54
|
+
blockHeadingTag('pricingTable') ?? 'h2',
|
|
55
|
+
{ class: 'cg-pricing__title', 'data-field': 'title' },
|
|
56
|
+
block.title ?? '',
|
|
57
|
+
)
|
|
58
|
+
: null,
|
|
59
|
+
h(
|
|
60
|
+
'ul',
|
|
61
|
+
{ class: 'cg-pricing__tiers' },
|
|
62
|
+
block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { StatCounterBlock, StatCounterItem } from '@cogenta/blocks'
|
|
2
|
+
import type { RenderContext } from '../../theme-contract.js'
|
|
3
|
+
import { blockHeadingTag, heading } from '../heading.js'
|
|
4
|
+
import { type HtmlElement, h } from '../html.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `blocks@2.0` (RFC 0001). A `<dl>`, exactly like `stats` — each figure is
|
|
8
|
+
* the description of its label — but narrower on purpose: no `unit`, since a
|
|
9
|
+
* counter row is meant for one big figure and its label, not the fuller
|
|
10
|
+
* statistic `stats` already covers.
|
|
11
|
+
*/
|
|
12
|
+
function renderItem(item: StatCounterItem): HtmlElement {
|
|
13
|
+
return h(
|
|
14
|
+
'div',
|
|
15
|
+
{ class: 'cg-stat-counter' },
|
|
16
|
+
h('dt', { class: 'cg-stat-counter__label' }, item.label),
|
|
17
|
+
h('dd', { class: 'cg-stat-counter__value' }, item.value),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
|
|
22
|
+
return h(
|
|
23
|
+
'section',
|
|
24
|
+
{ class: 'cg-block cg-stat-counters', 'data-block': 'statCounter' },
|
|
25
|
+
block.title === undefined
|
|
26
|
+
? null
|
|
27
|
+
: heading(
|
|
28
|
+
blockHeadingTag('statCounter') ?? 'h2',
|
|
29
|
+
{ class: 'cg-stat-counters__title', 'data-field': 'title' },
|
|
30
|
+
block.title,
|
|
31
|
+
),
|
|
32
|
+
h('dl', { class: 'cg-stat-counters__items' }, block.stats.map(renderItem)),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { TestimonialBlock } from '@cogenta/blocks'
|
|
2
|
+
import type { RenderContext } from '../../theme-contract.js'
|
|
3
|
+
import { type HtmlElement, h } from '../html.js'
|
|
4
|
+
import { image } from '../media.js'
|
|
5
|
+
import { renderRichText } from '../rich-text.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `blocks@2.0` (RFC 0001). Same `<figure><blockquote>…<figcaption>` pattern
|
|
9
|
+
* as `quote`, but the quote itself is rich text (a testimonial can carry a
|
|
10
|
+
* link or emphasis the shorter, plain-text `quote` block cannot) and the
|
|
11
|
+
* attribution is one grouped field rather than three loose ones.
|
|
12
|
+
*
|
|
13
|
+
* The avatar is decorative, exactly like `quote`'s own: the name is right
|
|
14
|
+
* beside it in text, so its media entity's alt text is expected to be empty.
|
|
15
|
+
*/
|
|
16
|
+
export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
|
|
17
|
+
const { attribution } = block
|
|
18
|
+
return h(
|
|
19
|
+
'figure',
|
|
20
|
+
{ class: 'cg-block cg-testimonial', 'data-block': 'testimonial' },
|
|
21
|
+
h('blockquote', { class: 'cg-testimonial__quote' }, renderRichText(ctx, block.quote)),
|
|
22
|
+
h(
|
|
23
|
+
'figcaption',
|
|
24
|
+
{ class: 'cg-testimonial__attribution' },
|
|
25
|
+
attribution.avatar === undefined
|
|
26
|
+
? null
|
|
27
|
+
: image(ctx, attribution.avatar, {
|
|
28
|
+
className: 'cg-testimonial__avatar',
|
|
29
|
+
variant: { width: 96, height: 96, fit: 'cover' },
|
|
30
|
+
}),
|
|
31
|
+
h(
|
|
32
|
+
'span',
|
|
33
|
+
{ class: 'cg-testimonial__names' },
|
|
34
|
+
h('span', { class: 'cg-testimonial__name' }, attribution.name),
|
|
35
|
+
attribution.role === undefined
|
|
36
|
+
? null
|
|
37
|
+
: h('span', { class: 'cg-testimonial__role' }, attribution.role),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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 site header and footer — moved here, verbatim, from what used to be
|
|
15
|
+
* `@cogenta/cli`'s own hardcoded `<header class="cg-site-header">`/`<footer
|
|
16
|
+
* class="cg-site-footer">` template (`packages/cli/src/commands/theme-render.ts`,
|
|
17
|
+
* before the chrome extension point existed). Byte-identical output to that
|
|
18
|
+
* template: this is the reference theme, and every site that installed it
|
|
19
|
+
* before a second theme existed must render exactly as it always has.
|
|
20
|
+
*
|
|
21
|
+
* A future theme with a structurally different header (a mega-menu, a cart
|
|
22
|
+
* icon, no header at all) implements its own `renderChrome` instead — this is
|
|
23
|
+
* simply the one this package ships.
|
|
24
|
+
*
|
|
25
|
+
* `theme@1.4` (L25 D2) adds four optional fields, each rendered only when
|
|
26
|
+
* present: `headerAction` as a button-styled link at the end of the header
|
|
27
|
+
* nav, `tagline` under the site name in the footer, `social` as an icon-link
|
|
28
|
+
* row (`renderSocialLinks`), `footerNote` as a short "about" paragraph. A
|
|
29
|
+
* site that sets none of them, and a `1.3` render that never reaches this
|
|
30
|
+
* file at all, both keep the pre-1.4 header/footer byte for byte — see
|
|
31
|
+
* `test/chrome.test.ts`'s own "without the new fields" case.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
function renderNavLinks(links: readonly ChromeNavLink[]): string {
|
|
35
|
+
if (links.length === 0) return ''
|
|
36
|
+
const items = links
|
|
37
|
+
.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
|
|
38
|
+
.map((link) => {
|
|
39
|
+
const label = escapeText(link.label)
|
|
40
|
+
const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
|
|
41
|
+
if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
|
|
42
|
+
const href = escapeAttribute(link.href)
|
|
43
|
+
const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
|
|
44
|
+
return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
|
|
45
|
+
})
|
|
46
|
+
.join('')
|
|
47
|
+
return items === '' ? '' : `<ul class="cg-menu">${items}</ul>`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The header's own call-to-action link (`theme@1.4`), styled like the block vocabulary's own primary action. */
|
|
51
|
+
function renderHeaderAction(action: ChromeInput['headerAction']): string {
|
|
52
|
+
if (action === undefined) return ''
|
|
53
|
+
return (
|
|
54
|
+
`<a class="cg-action cg-site-header__action" data-emphasis="primary" ` +
|
|
55
|
+
`href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderChrome(input: ChromeInput): ChromeResult {
|
|
60
|
+
const siteName = escapeAttribute(input.site.name)
|
|
61
|
+
const headerNav = renderNavLinks(input.headerNav)
|
|
62
|
+
const footerNav = renderNavLinks(input.footerNav)
|
|
63
|
+
// The uploaded logo replaces the wordmark, and only the wordmark: the
|
|
64
|
+
// footer keeps the site's name in text, so a site whose logo fails to load
|
|
65
|
+
// is still named somewhere on every page.
|
|
66
|
+
const mark = renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ?? siteName
|
|
67
|
+
const headerAction = renderHeaderAction(input.headerAction)
|
|
68
|
+
const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
|
|
69
|
+
|
|
70
|
+
const header =
|
|
71
|
+
`<header class="cg-site-header"><div class="cg-site-header__inner">` +
|
|
72
|
+
`<a class="cg-site-header__home" href="${escapeAttribute(input.homeHref)}">${mark}</a>` +
|
|
73
|
+
`${headerNav === '' ? '' : `<nav class="cg-site-header__nav" aria-label="Primary">${headerNav}</nav>`}` +
|
|
74
|
+
`${themeToggle}` +
|
|
75
|
+
`${headerAction}` +
|
|
76
|
+
`</div></header>`
|
|
77
|
+
|
|
78
|
+
const tagline =
|
|
79
|
+
input.tagline === undefined
|
|
80
|
+
? ''
|
|
81
|
+
: `<p class="cg-site-footer__tagline">${escapeText(input.tagline)}</p>`
|
|
82
|
+
const social =
|
|
83
|
+
input.social === undefined
|
|
84
|
+
? ''
|
|
85
|
+
: serialize(
|
|
86
|
+
renderSocialLinks(input.social, {
|
|
87
|
+
className: 'cg-site-footer__social',
|
|
88
|
+
itemClassName: 'cg-site-footer__social-item',
|
|
89
|
+
}) ?? { kind: 'text', value: '' },
|
|
90
|
+
)
|
|
91
|
+
const footerNote =
|
|
92
|
+
input.footerNote === undefined
|
|
93
|
+
? ''
|
|
94
|
+
: `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
|
|
95
|
+
|
|
96
|
+
const footer =
|
|
97
|
+
`<footer class="cg-site-footer"><div class="cg-site-footer__inner">` +
|
|
98
|
+
`<span>${siteName}</span>${tagline}` +
|
|
99
|
+
`${footerNav === '' ? '' : `<nav class="cg-site-footer__nav" aria-label="Footer">${footerNav}</nav>`}` +
|
|
100
|
+
`${social}` +
|
|
101
|
+
`${footerNote === '' ? '' : `<div class="cg-site-footer__about">${footerNote}</div>`}` +
|
|
102
|
+
`${input.brandingHtml}</div></footer>`
|
|
103
|
+
|
|
104
|
+
return { header, footer }
|
|
105
|
+
}
|
package/src/render/entry.ts
CHANGED
|
@@ -1,40 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Contract A fixes the system fields but not the schema-defined ones: which
|
|
5
|
-
* fields an entry has depends on its collection, and a theme that assumes
|
|
6
|
-
* `title` exists breaks on the first collection that calls it `name`.
|
|
7
|
-
*
|
|
8
|
-
* So every read of an unknown field goes through here, returns a fallback the
|
|
9
|
-
* visitor can live with, and never renders `undefined`.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
const TITLE_FIELDS = ['title', 'name', 'label'] as const
|
|
13
|
-
|
|
14
|
-
export function entryTitle(entry: ContentEntry, ctx: RenderContext): string {
|
|
15
|
-
for (const field of TITLE_FIELDS) {
|
|
16
|
-
const value = entry[field]
|
|
17
|
-
if (typeof value === 'string' && value.trim() !== '') return value
|
|
18
|
-
}
|
|
19
|
-
return ctx.t('entry.untitled')
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const EXCERPT_FIELDS = ['excerpt', 'summary', 'description'] as const
|
|
23
|
-
|
|
24
|
-
export function entryExcerpt(entry: ContentEntry): string | undefined {
|
|
25
|
-
for (const field of EXCERPT_FIELDS) {
|
|
26
|
-
const value = entry[field]
|
|
27
|
-
if (typeof value === 'string' && value.trim() !== '') return value
|
|
28
|
-
}
|
|
29
|
-
return undefined
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** ISO 8601, for `<time datetime>`. Anything else is not a date this can use. */
|
|
33
|
-
export function entryDate(entry: ContentEntry): string | undefined {
|
|
34
|
-
const value = entry.publishedAt ?? entry.createdAt
|
|
35
|
-
return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? value : undefined
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function entryHref(entry: ContentEntry, ctx: RenderContext): string {
|
|
39
|
-
return ctx.link({ collection: entry.collection, id: entry.id })
|
|
40
|
-
}
|
|
1
|
+
/** Re-exported from `@cogenta/theme-kit` — see that package's `entry.ts`. */
|
|
2
|
+
export { entryDate, entryExcerpt, entryHref, entryImage, entryTitle } from '@cogenta/theme-kit'
|
package/src/render/heading.ts
CHANGED
|
@@ -1,44 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* than restating it. Restating it is how an outline drifts — one block gets
|
|
9
|
-
* promoted in the theme, the page grows a second `h1`, and nothing fails.
|
|
10
|
-
*/
|
|
11
|
-
const DECLARED_LEVELS: ReadonlyMap<string, HeadingLevel> = new Map(
|
|
12
|
-
VOCABULARY.map((block) => [block.name, block.a11y.headingLevel]),
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
|
16
|
-
|
|
17
|
-
/** The level a block's own title is rendered at, or `null` if it carries none. */
|
|
18
|
-
export function blockHeadingTag(blockName: string): HeadingTag | null {
|
|
19
|
-
const level = DECLARED_LEVELS.get(blockName)
|
|
20
|
-
if (level === undefined || level === 'none') return null
|
|
21
|
-
return level
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The level for a title *inside* a block — a feature's name, a listed entry.
|
|
26
|
-
*
|
|
27
|
-
* One below the block's own heading when the block renders one, and at the
|
|
28
|
-
* block's declared level when it does not: a `featureGrid` without a title must
|
|
29
|
-
* not start its items at `h3`, which would skip a level.
|
|
30
|
-
*/
|
|
31
|
-
export function nestedHeadingTag(blockName: string, blockRendersHeading: boolean): HeadingTag {
|
|
32
|
-
const own = blockHeadingTag(blockName) ?? 'h2'
|
|
33
|
-
if (!blockRendersHeading) return own
|
|
34
|
-
const next = Number(own.slice(1)) + 1
|
|
35
|
-
return `h${Math.min(next, 6)}` as HeadingTag
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function heading(
|
|
39
|
-
tag: HeadingTag,
|
|
40
|
-
attrs: Readonly<Record<string, string | number | boolean | undefined>>,
|
|
41
|
-
...children: readonly Child[]
|
|
42
|
-
): HtmlElement {
|
|
43
|
-
return h(tag, attrs, ...children)
|
|
44
|
-
}
|
|
1
|
+
/** Re-exported from `@cogenta/theme-kit` — see that package's `heading.ts`. */
|
|
2
|
+
export {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HeadingTag,
|
|
5
|
+
heading,
|
|
6
|
+
nestedHeadingTag,
|
|
7
|
+
} from '@cogenta/theme-kit'
|
package/src/render/html.ts
CHANGED
|
@@ -1,122 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
export type AttributeValue = string | number | boolean | undefined
|
|
19
|
-
|
|
20
|
-
export type Attributes = Readonly<Record<string, AttributeValue>>
|
|
21
|
-
|
|
22
|
-
export interface HtmlElement {
|
|
23
|
-
readonly kind: 'element'
|
|
24
|
-
readonly tag: string
|
|
25
|
-
readonly attrs: Attributes
|
|
26
|
-
readonly children: readonly HtmlNode[]
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface HtmlText {
|
|
30
|
-
readonly kind: 'text'
|
|
31
|
-
readonly value: string
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type HtmlNode = HtmlElement | HtmlText
|
|
35
|
-
|
|
36
|
-
/** Anything a caller may pass as a child. `null`/`false` drop out, so a
|
|
37
|
-
* conditional child reads as `condition && node` without a wrapper. */
|
|
38
|
-
export type Child = HtmlNode | string | null | false | undefined | readonly Child[]
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Elements that carry no children and no closing tag. Serialising `<img>` with
|
|
42
|
-
* one would produce markup no parser agrees on.
|
|
43
|
-
*/
|
|
44
|
-
const VOID_ELEMENTS: ReadonlySet<string> = new Set([
|
|
45
|
-
'area',
|
|
46
|
-
'base',
|
|
47
|
-
'br',
|
|
48
|
-
'col',
|
|
49
|
-
'embed',
|
|
50
|
-
'hr',
|
|
51
|
-
'img',
|
|
52
|
-
'input',
|
|
53
|
-
'link',
|
|
54
|
-
'meta',
|
|
55
|
-
'source',
|
|
56
|
-
'track',
|
|
57
|
-
'wbr',
|
|
58
|
-
])
|
|
59
|
-
|
|
60
|
-
export function text(value: string): HtmlText {
|
|
61
|
-
return { kind: 'text', value }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function flatten(children: readonly Child[], into: HtmlNode[]): void {
|
|
65
|
-
for (const child of children) {
|
|
66
|
-
if (child === null || child === false || child === undefined) continue
|
|
67
|
-
if (typeof child === 'string') {
|
|
68
|
-
into.push(text(child))
|
|
69
|
-
continue
|
|
70
|
-
}
|
|
71
|
-
if (Array.isArray(child)) {
|
|
72
|
-
flatten(child as readonly Child[], into)
|
|
73
|
-
continue
|
|
74
|
-
}
|
|
75
|
-
into.push(child as HtmlNode)
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function h(tag: string, attrs: Attributes = {}, ...children: readonly Child[]): HtmlElement {
|
|
80
|
-
const collected: HtmlNode[] = []
|
|
81
|
-
flatten(children, collected)
|
|
82
|
-
return { kind: 'element', tag, attrs, children: collected }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* `&` first, or every following replacement would be escaped twice.
|
|
87
|
-
* `<` and `>` cover text; the quotes are escaped in attributes only, where they
|
|
88
|
-
* are the ones that can break out.
|
|
89
|
-
*/
|
|
90
|
-
export function escapeText(value: string): string {
|
|
91
|
-
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function escapeAttribute(value: string): string {
|
|
95
|
-
return escapeText(value).replace(/"/g, '"').replace(/'/g, ''')
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function serializeAttributes(attrs: Attributes): string {
|
|
99
|
-
let out = ''
|
|
100
|
-
// Insertion order, not sorted: it is deterministic already, and an author
|
|
101
|
-
// reading a snapshot expects the order they wrote.
|
|
102
|
-
for (const [name, value] of Object.entries(attrs)) {
|
|
103
|
-
if (value === undefined || value === false) continue
|
|
104
|
-
if (value === true) {
|
|
105
|
-
out += ` ${name}`
|
|
106
|
-
continue
|
|
107
|
-
}
|
|
108
|
-
out += ` ${name}="${escapeAttribute(String(value))}"`
|
|
109
|
-
}
|
|
110
|
-
return out
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function serialize(node: HtmlNode): string {
|
|
114
|
-
if (node.kind === 'text') return escapeText(node.value)
|
|
115
|
-
const open = `<${node.tag}${serializeAttributes(node.attrs)}>`
|
|
116
|
-
if (VOID_ELEMENTS.has(node.tag)) return open
|
|
117
|
-
return `${open}${node.children.map(serialize).join('')}</${node.tag}>`
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function serializeAll(nodes: readonly HtmlNode[]): string {
|
|
121
|
-
return nodes.map(serialize).join('')
|
|
122
|
-
}
|
|
1
|
+
/** Re-exported from `@cogenta/theme-kit` — see that package's `html.ts`. */
|
|
2
|
+
export {
|
|
3
|
+
type Attributes,
|
|
4
|
+
type AttributeValue,
|
|
5
|
+
type Child,
|
|
6
|
+
escapeAttribute,
|
|
7
|
+
escapeText,
|
|
8
|
+
type HtmlElement,
|
|
9
|
+
type HtmlNode,
|
|
10
|
+
type HtmlText,
|
|
11
|
+
h,
|
|
12
|
+
serialize,
|
|
13
|
+
serializeAll,
|
|
14
|
+
text,
|
|
15
|
+
} from '@cogenta/theme-kit'
|