@cogenta/theme-canonical 1.1.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/render/arrow-link.d.ts +11 -0
- package/dist/src/render/arrow-link.d.ts.map +1 -0
- package/dist/src/render/arrow-link.js +29 -0
- package/dist/src/render/arrow-link.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +15 -5
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
- package/dist/src/render/blocks/collection-list.js +36 -13
- package/dist/src/render/blocks/collection-list.js.map +1 -1
- package/dist/src/render/blocks/embed.d.ts.map +1 -1
- package/dist/src/render/blocks/embed.js +3 -2
- package/dist/src/render/blocks/embed.js.map +1 -1
- package/dist/src/render/blocks/gallery.d.ts.map +1 -1
- package/dist/src/render/blocks/gallery.js +4 -1
- package/dist/src/render/blocks/gallery.js.map +1 -1
- package/dist/src/render/blocks/hero.d.ts.map +1 -1
- package/dist/src/render/blocks/hero.js +9 -2
- package/dist/src/render/blocks/hero.js.map +1 -1
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
- package/dist/src/render/blocks/pricing-table.js +17 -4
- package/dist/src/render/blocks/pricing-table.js.map +1 -1
- package/dist/src/render/chrome.d.ts +40 -1
- package/dist/src/render/chrome.d.ts.map +1 -1
- package/dist/src/render/chrome.js +103 -71
- package/dist/src/render/chrome.js.map +1 -1
- package/dist/src/render/dates.d.ts +9 -0
- package/dist/src/render/dates.d.ts.map +1 -0
- package/dist/src/render/dates.js +16 -0
- package/dist/src/render/dates.js.map +1 -0
- package/dist/src/render/render-block.d.ts.map +1 -1
- package/dist/src/render/render-block.js +7 -1
- package/dist/src/render/render-block.js.map +1 -1
- package/dist/src/render/strings.d.ts +25 -0
- package/dist/src/render/strings.d.ts.map +1 -0
- package/dist/src/render/strings.js +26 -0
- package/dist/src/render/strings.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +5 -5
- package/dist/src/render/term-archive.d.ts.map +1 -1
- package/dist/src/render/term-archive.js +14 -12
- package/dist/src/render/term-archive.js.map +1 -1
- package/dist/src/render/widget-areas.d.ts +20 -0
- package/dist/src/render/widget-areas.d.ts.map +1 -0
- package/dist/src/render/widget-areas.js +40 -0
- package/dist/src/render/widget-areas.js.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +1 -0
- package/src/render/arrow-link.ts +27 -0
- package/src/render/blocks/collection-list.ts +53 -15
- package/src/render/blocks/embed.ts +3 -2
- package/src/render/blocks/gallery.ts +4 -1
- package/src/render/blocks/hero.ts +9 -2
- package/src/render/blocks/pricing-table.ts +22 -4
- package/src/render/chrome.ts +156 -70
- package/src/render/dates.ts +14 -0
- package/src/render/render-block.ts +6 -0
- package/src/render/strings.ts +30 -0
- package/src/render/term-archive.ts +54 -32
- package/src/render/widget-areas.ts +44 -0
- package/src/styles/archive.css +66 -20
- package/src/styles/base.css +266 -680
- package/src/styles/blocks.css +678 -811
- package/src/styles/chrome.css +469 -0
- package/src/styles/listings.css +183 -0
- package/src/styles/theme.css +28 -14
- package/src/styles/tokens.css +192 -205
- package/src/styles/utility.css +295 -0
- package/src/styles/widgets.css +1064 -0
- package/tokens.json +15 -15
|
@@ -1,47 +1,80 @@
|
|
|
1
1
|
import type { CollectionListBlock } from '@cogenta/blocks'
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { renderImageSource } from '@cogenta/theme-kit'
|
|
3
|
+
import type { ContentEntry, ImageSource, RenderContext } from '../../theme-contract.js'
|
|
4
|
+
import { formatEntryDate } from '../dates.js'
|
|
5
|
+
import { entryDate, entryExcerpt, entryHref, entryImage, entryTitle } from '../entry.js'
|
|
4
6
|
import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
|
|
5
7
|
import { type HtmlElement, h } from '../html.js'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
* The only block of the
|
|
9
|
-
* marks it `runtime: 'server'` for that reason.
|
|
10
|
+
* The only block of the vocabulary that reads data at render time — contract
|
|
11
|
+
* B marks it `runtime: 'server'` for that reason.
|
|
10
12
|
*
|
|
11
13
|
* The read is *not* done here. `query` builds the request (`@cogenta/theme-kit`'s
|
|
12
14
|
* `buildCollectionListQuery`, shared across every theme since it derives
|
|
13
15
|
* purely from the block's own fields), the caller awaits `ctx.content.list(...)`
|
|
14
16
|
* before rendering starts, and this function stays a pure function of the
|
|
15
|
-
* entries it is handed.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* entries it is handed.
|
|
18
|
+
*
|
|
19
|
+
* Three layouts, three readings of the same entries:
|
|
20
|
+
*
|
|
21
|
+
* - `list` is an index: the date in its own narrow column, then the title and
|
|
22
|
+
* the summary. No picture: a reader scans an index by date and title.
|
|
23
|
+
* - `grid` sets entries in columns, each opened by its picture
|
|
24
|
+
* (`entryImage`, `theme@1.4`) — but only when every entry of the list has
|
|
25
|
+
* one. A row where one entry opens on a photograph and its neighbour on
|
|
26
|
+
* nothing reads as a missing image, so a list with any entry lacking a
|
|
27
|
+
* picture is set in type alone, every entry opening on the same rule.
|
|
28
|
+
* - `carousel` is the grid as a horizontal row the reader scrolls, under the
|
|
29
|
+
* same all-or-none rule for pictures.
|
|
18
30
|
*/
|
|
19
31
|
export { buildCollectionListQuery as query } from '@cogenta/theme-kit'
|
|
20
32
|
|
|
21
|
-
function renderEntry(
|
|
33
|
+
function renderEntry(
|
|
34
|
+
entry: ContentEntry,
|
|
35
|
+
ctx: RenderContext,
|
|
36
|
+
tag: HeadingTag,
|
|
37
|
+
picture: ImageSource | undefined,
|
|
38
|
+
): HtmlElement {
|
|
22
39
|
const date = entryDate(entry)
|
|
23
40
|
const excerpt = entryExcerpt(entry)
|
|
41
|
+
const href = entryHref(entry, ctx)
|
|
24
42
|
return h(
|
|
25
43
|
'li',
|
|
26
|
-
{ class: 'cg-entry' },
|
|
44
|
+
{ class: 'cg-entry', 'data-picture': picture === undefined ? 'false' : 'true' },
|
|
27
45
|
h(
|
|
28
46
|
'article',
|
|
29
47
|
{ class: 'cg-entry__body' },
|
|
30
48
|
heading(
|
|
31
49
|
tag,
|
|
32
50
|
{ class: 'cg-entry__title' },
|
|
33
|
-
h('a', { class: 'cg-entry__link', href
|
|
51
|
+
h('a', { class: 'cg-entry__link', href }, entryTitle(entry, ctx)),
|
|
34
52
|
),
|
|
35
53
|
date === undefined
|
|
36
54
|
? null
|
|
37
55
|
: h(
|
|
38
56
|
'time',
|
|
39
57
|
{ class: 'cg-entry__date', datetime: date },
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
new Intl.DateTimeFormat(ctx.locale, { dateStyle: 'long' }).format(new Date(date)),
|
|
58
|
+
// The machine-readable form is already in `datetime`.
|
|
59
|
+
formatEntryDate(date, ctx.locale),
|
|
43
60
|
),
|
|
44
61
|
excerpt === undefined ? null : h('p', { class: 'cg-entry__excerpt' }, excerpt),
|
|
62
|
+
picture === undefined
|
|
63
|
+
? null
|
|
64
|
+
: h(
|
|
65
|
+
'a',
|
|
66
|
+
// The picture follows the title's link for a pointer, and is left
|
|
67
|
+
// out of the tab order and of the accessibility tree: the title
|
|
68
|
+
// already is the one named link to this entry.
|
|
69
|
+
{ class: 'cg-entry__media', href, tabindex: '-1', 'aria-hidden': 'true' },
|
|
70
|
+
renderImageSource(
|
|
71
|
+
{ ...picture, alt: '' },
|
|
72
|
+
{
|
|
73
|
+
className: 'cg-entry__image',
|
|
74
|
+
sizes: '(min-width: 72rem) 23rem, (min-width: 40rem) 50vw, 100vw',
|
|
75
|
+
},
|
|
76
|
+
),
|
|
77
|
+
),
|
|
45
78
|
),
|
|
46
79
|
)
|
|
47
80
|
}
|
|
@@ -53,13 +86,18 @@ export function renderCollectionList(
|
|
|
53
86
|
): HtmlElement {
|
|
54
87
|
const hasTitle = block.title !== undefined
|
|
55
88
|
const entryTag = nestedHeadingTag('collectionList', hasTitle)
|
|
89
|
+
const pictures =
|
|
90
|
+
block.layout === 'list' ? [] : entries.map((entry) => entryImage(entry, ctx, { width: 960 }))
|
|
91
|
+
const everyPicture = pictures.length > 0 && pictures.every((picture) => picture !== undefined)
|
|
56
92
|
const items =
|
|
57
93
|
entries.length === 0
|
|
58
94
|
? h('p', { class: 'cg-collection__empty' }, ctx.t('collection.empty'))
|
|
59
95
|
: h(
|
|
60
96
|
'ul',
|
|
61
97
|
{ class: 'cg-collection__items' },
|
|
62
|
-
entries.map((entry) =>
|
|
98
|
+
entries.map((entry, index) =>
|
|
99
|
+
renderEntry(entry, ctx, entryTag, everyPicture ? pictures[index] : undefined),
|
|
100
|
+
),
|
|
63
101
|
)
|
|
64
102
|
|
|
65
103
|
return h(
|
|
@@ -76,7 +114,7 @@ export function renderCollectionList(
|
|
|
76
114
|
block.title ?? '',
|
|
77
115
|
)
|
|
78
116
|
: null,
|
|
79
|
-
block.layout === 'carousel'
|
|
117
|
+
block.layout === 'carousel' && entries.length > 0
|
|
80
118
|
? h(
|
|
81
119
|
'div',
|
|
82
120
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EmbedBlock } from '@cogenta/blocks'
|
|
2
2
|
import type { RenderContext } from '../../theme-contract.js'
|
|
3
|
+
import { arrowWords } from '../arrow-link.js'
|
|
3
4
|
import { type HtmlElement, h } from '../html.js'
|
|
4
5
|
import { aspectRatio } from '../media.js'
|
|
5
6
|
|
|
@@ -64,11 +65,11 @@ function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): Htm
|
|
|
64
65
|
h(
|
|
65
66
|
'a',
|
|
66
67
|
{
|
|
67
|
-
class: 'cg-embed__link',
|
|
68
|
+
class: 'cg-arrow-link cg-embed__link',
|
|
68
69
|
href: ctx.link(block.url),
|
|
69
70
|
rel: 'noopener noreferrer nofollow',
|
|
70
71
|
},
|
|
71
|
-
ctx.t('embed.open', { provider: block.provider }),
|
|
72
|
+
arrowWords(ctx.t('embed.open', { provider: block.provider })),
|
|
72
73
|
),
|
|
73
74
|
)
|
|
74
75
|
}
|
|
@@ -21,7 +21,10 @@ export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElem
|
|
|
21
21
|
h(
|
|
22
22
|
'li',
|
|
23
23
|
{ class: 'cg-gallery__item' },
|
|
24
|
-
image(ctx, item.media, {
|
|
24
|
+
image(ctx, item.media, {
|
|
25
|
+
className: 'cg-gallery__image',
|
|
26
|
+
sizes: '(min-width: 64rem) 24rem, 50vw',
|
|
27
|
+
}),
|
|
25
28
|
),
|
|
26
29
|
),
|
|
27
30
|
)
|
|
@@ -16,7 +16,13 @@ export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
|
|
|
16
16
|
const tag = blockHeadingTag('hero') ?? 'h1'
|
|
17
17
|
return h(
|
|
18
18
|
'section',
|
|
19
|
-
{
|
|
19
|
+
{
|
|
20
|
+
class: 'cg-block cg-hero',
|
|
21
|
+
'data-block': 'hero',
|
|
22
|
+
// Two compositions: words alone, or words over a wide photograph. The
|
|
23
|
+
// attribute says which, so the stylesheet never has to guess.
|
|
24
|
+
'data-media': block.media === undefined ? 'false' : 'true',
|
|
25
|
+
},
|
|
20
26
|
h(
|
|
21
27
|
'div',
|
|
22
28
|
{ class: 'cg-hero__body' },
|
|
@@ -39,7 +45,8 @@ export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
|
|
|
39
45
|
// is a measured Lighthouse regression, not a theoretical one.
|
|
40
46
|
image(ctx, block.media, {
|
|
41
47
|
loading: 'eager',
|
|
42
|
-
|
|
48
|
+
className: 'cg-hero__image',
|
|
49
|
+
sizes: '(min-width: 72rem) 72rem, 100vw',
|
|
43
50
|
}),
|
|
44
51
|
),
|
|
45
52
|
)
|
|
@@ -3,11 +3,17 @@ import type { RenderContext } from '../../theme-contract.js'
|
|
|
3
3
|
import { actionLink } 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 { word } from '../strings.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* `blocks@2.0` (RFC 0001). `highlighted` is editorial emphasis, not a colour:
|
|
9
|
-
* it becomes `data-highlighted` for the
|
|
10
|
-
*
|
|
10
|
+
* it becomes `data-highlighted` for the stylesheet, `aria-current` (loosely
|
|
11
|
+
* "the one on offer") so it is announced, and a short label under the plan's
|
|
12
|
+
* name so it is read in words, never only seen as a heavier rule.
|
|
13
|
+
*
|
|
14
|
+
* Each plan is four rows (name, price, features, action) that the stylesheet
|
|
15
|
+
* lines up across plans, so prices sit on one line and actions on another
|
|
16
|
+
* whatever the length of each feature list.
|
|
11
17
|
*/
|
|
12
18
|
function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
13
19
|
return h(
|
|
@@ -17,7 +23,14 @@ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): Htm
|
|
|
17
23
|
'data-highlighted': tier.highlighted === true ? 'true' : undefined,
|
|
18
24
|
'aria-current': tier.highlighted === true ? 'true' : undefined,
|
|
19
25
|
},
|
|
20
|
-
|
|
26
|
+
h(
|
|
27
|
+
'div',
|
|
28
|
+
{ class: 'cg-pricing__head' },
|
|
29
|
+
heading(tag, { class: 'cg-pricing__name' }, tier.name),
|
|
30
|
+
tier.highlighted === true
|
|
31
|
+
? h('p', { class: 'cg-pricing__flag' }, word(ctx.locale, 'recommended'))
|
|
32
|
+
: null,
|
|
33
|
+
),
|
|
21
34
|
h(
|
|
22
35
|
'p',
|
|
23
36
|
{ class: 'cg-pricing__price' },
|
|
@@ -38,7 +51,12 @@ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): Htm
|
|
|
38
51
|
: h(
|
|
39
52
|
'div',
|
|
40
53
|
{ class: 'cg-pricing__action' },
|
|
41
|
-
actionLink(ctx, {
|
|
54
|
+
actionLink(ctx, {
|
|
55
|
+
...tier.action,
|
|
56
|
+
// An emphasis the editor left unset follows the plan: the
|
|
57
|
+
// recommended plan gets the filled button, the others an outline.
|
|
58
|
+
emphasis: tier.action.emphasis ?? (tier.highlighted === true ? 'primary' : 'secondary'),
|
|
59
|
+
}),
|
|
42
60
|
),
|
|
43
61
|
)
|
|
44
62
|
}
|
package/src/render/chrome.ts
CHANGED
|
@@ -5,101 +5,187 @@ import {
|
|
|
5
5
|
escapeAttribute,
|
|
6
6
|
escapeText,
|
|
7
7
|
renderBrandMark,
|
|
8
|
+
renderFooterWidgets,
|
|
8
9
|
renderSocialLinks,
|
|
9
10
|
renderThemeToggle,
|
|
10
11
|
serialize,
|
|
11
12
|
} from '@cogenta/theme-kit'
|
|
13
|
+
import { word } from './strings.js'
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
|
-
* The site header and footer
|
|
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.
|
|
16
|
+
* The site header and footer of the reference theme.
|
|
20
17
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* **Header.** One bar on the page's own ground, closed by a hairline: the
|
|
19
|
+
* site's name (or its uploaded logo) at the start, then the primary pages,
|
|
20
|
+
* then the `headerAction` (`theme@1.4`) as the page's one filled button and
|
|
21
|
+
* the light/dark control. The bar scrolls away with the page: a default theme
|
|
22
|
+
* has no reason to keep a strip of the window for itself.
|
|
24
23
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* nav
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
24
|
+
* Below the wide breakpoint the pages move into a panel under the bar, opened
|
|
25
|
+
* by a CSS-only control: a checkbox named by a visible "Menu" label, placed
|
|
26
|
+
* before the `<nav>` so the sibling combinator reaches it. No script. The
|
|
27
|
+
* source order (name, menu, pages, action, light/dark) is also the visual
|
|
28
|
+
* order at both widths, so keyboard focus never jumps around the bar.
|
|
29
|
+
*
|
|
30
|
+
* **Footer.** On a band a step off the page. The site's name, its `tagline`,
|
|
31
|
+
* the `footerNote` (an address or a legal mention, kept to its own lines) and
|
|
32
|
+
* the social profiles on the first columns; the footer menu on the last ones.
|
|
33
|
+
* An unlinked item of the footer menu (a `submenu-placeholder` such as
|
|
34
|
+
* "Company") starts a column and names it, so an editor builds the columns in
|
|
35
|
+
* the menu screen; a menu without one is a single column. Under a hairline,
|
|
36
|
+
* the legal line: the year and the site's name, then the host's
|
|
37
|
+
* `brandingHtml`, placed once and as received.
|
|
38
|
+
*
|
|
39
|
+
* **Footer widgets** (`theme@1.6`). The footer columns an editor fills in the
|
|
40
|
+
* widgets screen (`footer-1`..`footer-4`) are one row of their own, under the
|
|
41
|
+
* identity and the menu and above the legal line, on the same grid. A footer
|
|
42
|
+
* with no widget renders exactly as it did before.
|
|
43
|
+
*
|
|
44
|
+
* Every optional field renders only when present: a site that sets none of
|
|
45
|
+
* them gets no empty wrapper for any of them (`test/chrome.test.ts`).
|
|
32
46
|
*/
|
|
33
47
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
interface FooterGroup {
|
|
49
|
+
readonly heading: string | null
|
|
50
|
+
readonly links: readonly ChromeNavLink[]
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
/**
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
return (
|
|
54
|
-
`<a class="cg-action cg-site-header__action" data-emphasis="primary" ` +
|
|
55
|
-
`href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
|
|
56
|
-
)
|
|
53
|
+
/** Links a visitor can follow, plus the unlinked headings that name a column. */
|
|
54
|
+
function usable(links: readonly ChromeNavLink[]): readonly ChromeNavLink[] {
|
|
55
|
+
return links.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
58
|
+
function linkItem(link: ChromeNavLink): string {
|
|
59
|
+
const label = escapeText(link.label)
|
|
60
|
+
const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
|
|
61
|
+
if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
|
|
62
|
+
const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
|
|
63
|
+
return `<li><a href="${escapeAttribute(link.href)}"${target}${titleAttr}>${label}</a></li>`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The footer menu, split into columns at each unlinked heading. */
|
|
67
|
+
export function footerGroups(links: readonly ChromeNavLink[]): readonly FooterGroup[] {
|
|
68
|
+
const groups: { heading: string | null; links: ChromeNavLink[] }[] = []
|
|
69
|
+
for (const link of usable(links)) {
|
|
70
|
+
if (link.kind === 'submenu-placeholder' && link.href === null) {
|
|
71
|
+
groups.push({ heading: link.label, links: [] })
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const current = groups[groups.length - 1]
|
|
75
|
+
if (current === undefined) groups.push({ heading: null, links: [link] })
|
|
76
|
+
else current.links.push(link)
|
|
77
|
+
}
|
|
78
|
+
return groups.filter((group) => group.links.length > 0)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `footerNote` as paragraphs at blank lines, each keeping its own line breaks. */
|
|
82
|
+
function renderFooterNote(note: string): string {
|
|
83
|
+
const paragraphs = note
|
|
84
|
+
.replace(/\r\n?/g, '\n')
|
|
85
|
+
.split(/\n\s*\n/)
|
|
86
|
+
.map((paragraph) =>
|
|
87
|
+
paragraph
|
|
88
|
+
.split('\n')
|
|
89
|
+
.map((line) => line.trim())
|
|
90
|
+
.filter((line) => line !== ''),
|
|
91
|
+
)
|
|
92
|
+
.filter((lines) => lines.length > 0)
|
|
93
|
+
if (paragraphs.length === 0) return ''
|
|
94
|
+
return `<div class="cg-site-footer__about">${paragraphs
|
|
95
|
+
.map(
|
|
96
|
+
(lines) =>
|
|
97
|
+
`<p class="cg-site-footer__note">${lines.map((line) => escapeText(line)).join('<br>')}</p>`,
|
|
98
|
+
)
|
|
99
|
+
.join('')}</div>`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderHeader(input: ChromeInput): string {
|
|
103
|
+
const siteName = escapeText(input.site.name)
|
|
104
|
+
const home = escapeAttribute(input.homeHref)
|
|
63
105
|
// The uploaded logo replaces the wordmark, and only the wordmark: the
|
|
64
|
-
// footer keeps the site's name in text, so a
|
|
65
|
-
//
|
|
106
|
+
// footer keeps the site's name in text, so a page whose logo fails to load
|
|
107
|
+
// still names the site.
|
|
66
108
|
const mark = renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ?? siteName
|
|
67
|
-
const
|
|
68
|
-
const
|
|
109
|
+
const links = usable(input.headerNav).map(linkItem).join('')
|
|
110
|
+
const actionHref =
|
|
111
|
+
input.headerAction === undefined ? '' : escapeAttribute(input.headerAction.href)
|
|
112
|
+
const actionLabel = input.headerAction === undefined ? '' : escapeText(input.headerAction.label)
|
|
113
|
+
// On a phone the bar holds the name, the menu control and the light/dark
|
|
114
|
+
// control; the action moves to the foot of the menu panel. It is the same
|
|
115
|
+
// link twice in the source, and the stylesheet shows exactly one of them at
|
|
116
|
+
// any width (`display: none` hides the other from assistive technology
|
|
117
|
+
// too), so it is never announced twice.
|
|
118
|
+
const menuAction =
|
|
119
|
+
input.headerAction === undefined
|
|
120
|
+
? ''
|
|
121
|
+
: `<p class="cg-site-header__menu-action"><a class="cg-action" data-emphasis="primary" href="${actionHref}">${actionLabel}</a></p>`
|
|
122
|
+
const menu =
|
|
123
|
+
links === ''
|
|
124
|
+
? ''
|
|
125
|
+
: `<input type="checkbox" id="cg-nav-toggle" class="cg-nav-toggle" aria-controls="cg-site-nav">` +
|
|
126
|
+
`<label for="cg-nav-toggle" class="cg-nav-toggle__label">${escapeText(word(input.locale, 'menu'))}</label>` +
|
|
127
|
+
`<nav class="cg-site-header__nav" id="cg-site-nav" aria-label="${escapeAttribute(
|
|
128
|
+
word(input.locale, 'primaryNavigation'),
|
|
129
|
+
)}"><ul class="cg-menu">${links}</ul>${menuAction}</nav>`
|
|
130
|
+
const action =
|
|
131
|
+
input.headerAction === undefined
|
|
132
|
+
? ''
|
|
133
|
+
: `<a class="cg-action cg-site-header__action" data-emphasis="primary" href="${actionHref}">${actionLabel}</a>`
|
|
134
|
+
const toggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
|
|
69
135
|
|
|
70
|
-
|
|
71
|
-
`<header class="cg-site-header"><div class="cg-site-header__inner">` +
|
|
72
|
-
`<a class="cg-site-header__home" href="${
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
`${headerAction}` +
|
|
136
|
+
return (
|
|
137
|
+
`<header class="cg-site-header" data-nav="${links === '' ? 'none' : 'links'}"><div class="cg-site-header__inner">` +
|
|
138
|
+
`<a class="cg-site-header__home" href="${home}">${mark}</a>` +
|
|
139
|
+
menu +
|
|
140
|
+
`<div class="cg-site-header__end">${action}${toggle}</div>` +
|
|
76
141
|
`</div></header>`
|
|
142
|
+
)
|
|
143
|
+
}
|
|
77
144
|
|
|
145
|
+
function renderFooter(input: ChromeInput): string {
|
|
146
|
+
const siteName = escapeText(input.site.name)
|
|
78
147
|
const tagline =
|
|
79
148
|
input.tagline === undefined
|
|
80
149
|
? ''
|
|
81
150
|
: `<p class="cg-site-footer__tagline">${escapeText(input.tagline)}</p>`
|
|
82
|
-
const
|
|
83
|
-
|
|
151
|
+
const note = input.footerNote === undefined ? '' : renderFooterNote(input.footerNote)
|
|
152
|
+
const socialLinks = renderSocialLinks(input.social, {
|
|
153
|
+
className: 'cg-site-footer__social',
|
|
154
|
+
itemClassName: 'cg-site-footer__social-item',
|
|
155
|
+
})
|
|
156
|
+
const social = socialLinks === null ? '' : serialize(socialLinks)
|
|
157
|
+
const groups = footerGroups(input.footerNav)
|
|
158
|
+
const nav =
|
|
159
|
+
groups.length === 0
|
|
84
160
|
? ''
|
|
85
|
-
:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
161
|
+
: `<nav class="cg-site-footer__nav" aria-label="${escapeAttribute(
|
|
162
|
+
word(input.locale, 'footerNavigation'),
|
|
163
|
+
)}" data-columns="${Math.min(groups.length, 3)}">${groups
|
|
164
|
+
.map(
|
|
165
|
+
(group) =>
|
|
166
|
+
`<div class="cg-site-footer__group">${
|
|
167
|
+
group.heading === null
|
|
168
|
+
? ''
|
|
169
|
+
: `<p class="cg-site-footer__heading">${escapeText(group.heading)}</p>`
|
|
170
|
+
}<ul class="cg-menu">${group.links.map(linkItem).join('')}</ul></div>`,
|
|
171
|
+
)
|
|
172
|
+
.join('')}</nav>`
|
|
173
|
+
const widgets = renderFooterWidgets(input.widgets, {
|
|
174
|
+
className: 'cg-site-footer__widgets',
|
|
175
|
+
headingLevel: 'h2',
|
|
176
|
+
})
|
|
177
|
+
const year = new Date().getFullYear()
|
|
95
178
|
|
|
96
|
-
|
|
179
|
+
return (
|
|
97
180
|
`<footer class="cg-site-footer"><div class="cg-site-footer__inner">` +
|
|
98
|
-
`<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
181
|
+
`<div class="cg-site-footer__identity"><p class="cg-site-footer__name">${siteName}</p>${tagline}${note}${social}</div>` +
|
|
182
|
+
nav +
|
|
183
|
+
(widgets === null ? '' : serialize(widgets)) +
|
|
184
|
+
`<div class="cg-site-footer__legal"><p class="cg-site-footer__copyright">© ${year} ${siteName}</p>${input.brandingHtml}</div>` +
|
|
185
|
+
`</div></footer>`
|
|
186
|
+
)
|
|
187
|
+
}
|
|
103
188
|
|
|
104
|
-
|
|
189
|
+
export function renderChrome(input: ChromeInput): ChromeResult {
|
|
190
|
+
return { header: renderHeader(input), footer: renderFooter(input) }
|
|
105
191
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one date format of this theme — `Intl`'s long date, in the page's own
|
|
3
|
+
* language — shared by the collection list, the term archive and the entry
|
|
4
|
+
* header (`renderEntryHeader` formats the same way), so a date reads alike on
|
|
5
|
+
* every page. An invalid locale or date falls back to the ISO day, a true
|
|
6
|
+
* answer rather than a thrown error.
|
|
7
|
+
*/
|
|
8
|
+
export function formatEntryDate(iso: string, locale: string): string {
|
|
9
|
+
try {
|
|
10
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(new Date(iso))
|
|
11
|
+
} catch {
|
|
12
|
+
return iso.slice(0, 10)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type FetchedEntries,
|
|
4
4
|
type PageContent,
|
|
5
5
|
pageHasOwnHeading,
|
|
6
|
+
providedBlockNode,
|
|
6
7
|
renderEntryHeader,
|
|
7
8
|
resolveBlockForRender,
|
|
8
9
|
withBlockKey,
|
|
@@ -43,6 +44,11 @@ export function renderBlock(
|
|
|
43
44
|
entries: FetchedEntries = {},
|
|
44
45
|
registry?: BlockRegistry,
|
|
45
46
|
): HtmlElement | null {
|
|
47
|
+
// A block a plugin provides arrives already rendered by the host, in a
|
|
48
|
+
// process of its own (contract D theme@1.7). Nothing else in this theme
|
|
49
|
+
// needs to know such blocks exist.
|
|
50
|
+
const provided = providedBlockNode(block, ctx)
|
|
51
|
+
if (provided !== null) return withBlockVariant(provided, block.variant)
|
|
46
52
|
// `block`'s type says `VocabularyBlock`, but the value crossing this
|
|
47
53
|
// boundary from stored content is not always literally one of the twelve —
|
|
48
54
|
// `theme-render.ts` cannot know at compile time what a block zone holds.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { themeLocaleFor } from '@cogenta/theme-kit'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The few words this theme prints on its own, beyond `THEME_STRINGS`
|
|
5
|
+
* (`@cogenta/theme-kit`): the name of the menu control and of the two
|
|
6
|
+
* navigation landmarks, and the label over a recommended plan. The same two
|
|
7
|
+
* languages as the shared table, resolved the same way (`fr-CA` is French,
|
|
8
|
+
* anything unknown is English).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const WORDS = {
|
|
12
|
+
en: {
|
|
13
|
+
menu: 'Menu',
|
|
14
|
+
primaryNavigation: 'Primary',
|
|
15
|
+
footerNavigation: 'Footer',
|
|
16
|
+
recommended: 'Recommended',
|
|
17
|
+
},
|
|
18
|
+
fr: {
|
|
19
|
+
menu: 'Menu',
|
|
20
|
+
primaryNavigation: 'Principale',
|
|
21
|
+
footerNavigation: 'Pied de page',
|
|
22
|
+
recommended: 'Recommandé',
|
|
23
|
+
},
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export type ThemeWord = keyof (typeof WORDS)['en']
|
|
27
|
+
|
|
28
|
+
export function word(locale: string, key: ThemeWord): string {
|
|
29
|
+
return WORDS[themeLocaleFor(locale)][key]
|
|
30
|
+
}
|