@cogenta/theme-portfolio 1.1.3 → 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 +9 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +10 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/render/blocks/accordion.d.ts.map +1 -1
- package/dist/src/render/blocks/accordion.js +10 -18
- package/dist/src/render/blocks/accordion.js.map +1 -1
- package/dist/src/render/blocks/collection-list.d.ts +0 -20
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
- package/dist/src/render/blocks/collection-list.js +59 -66
- package/dist/src/render/blocks/collection-list.js.map +1 -1
- package/dist/src/render/blocks/cta.d.ts +4 -4
- package/dist/src/render/blocks/cta.d.ts.map +1 -1
- package/dist/src/render/blocks/cta.js +15 -10
- package/dist/src/render/blocks/cta.js.map +1 -1
- package/dist/src/render/blocks/embed.d.ts.map +1 -1
- package/dist/src/render/blocks/embed.js +16 -31
- package/dist/src/render/blocks/embed.js.map +1 -1
- package/dist/src/render/blocks/faq.d.ts.map +1 -1
- package/dist/src/render/blocks/faq.js +12 -17
- package/dist/src/render/blocks/faq.js.map +1 -1
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
- package/dist/src/render/blocks/feature-grid.js +26 -23
- package/dist/src/render/blocks/feature-grid.js.map +1 -1
- package/dist/src/render/blocks/gallery.d.ts +0 -14
- package/dist/src/render/blocks/gallery.d.ts.map +1 -1
- package/dist/src/render/blocks/gallery.js +29 -21
- package/dist/src/render/blocks/gallery.js.map +1 -1
- package/dist/src/render/blocks/hero.d.ts +8 -8
- package/dist/src/render/blocks/hero.d.ts.map +1 -1
- package/dist/src/render/blocks/hero.js +17 -18
- package/dist/src/render/blocks/hero.js.map +1 -1
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
- package/dist/src/render/blocks/logo-strip.js +9 -14
- package/dist/src/render/blocks/logo-strip.js.map +1 -1
- package/dist/src/render/blocks/logos.d.ts.map +1 -1
- package/dist/src/render/blocks/logos.js +14 -19
- package/dist/src/render/blocks/logos.js.map +1 -1
- package/dist/src/render/blocks/media-figure.d.ts +8 -12
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
- package/dist/src/render/blocks/media-figure.js +20 -20
- package/dist/src/render/blocks/media-figure.js.map +1 -1
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
- package/dist/src/render/blocks/pricing-table.js +17 -20
- package/dist/src/render/blocks/pricing-table.js.map +1 -1
- package/dist/src/render/blocks/prose.d.ts +10 -9
- package/dist/src/render/blocks/prose.d.ts.map +1 -1
- package/dist/src/render/blocks/prose.js +29 -10
- package/dist/src/render/blocks/prose.js.map +1 -1
- package/dist/src/render/blocks/quote.d.ts +5 -12
- package/dist/src/render/blocks/quote.d.ts.map +1 -1
- package/dist/src/render/blocks/quote.js +9 -15
- package/dist/src/render/blocks/quote.js.map +1 -1
- package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
- package/dist/src/render/blocks/stat-counter.js +10 -12
- package/dist/src/render/blocks/stat-counter.js.map +1 -1
- package/dist/src/render/blocks/stats.d.ts.map +1 -1
- package/dist/src/render/blocks/stats.js +7 -15
- package/dist/src/render/blocks/stats.js.map +1 -1
- package/dist/src/render/blocks/testimonial.d.ts +4 -14
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
- package/dist/src/render/blocks/testimonial.js +9 -18
- package/dist/src/render/blocks/testimonial.js.map +1 -1
- package/dist/src/render/chrome.d.ts.map +1 -1
- package/dist/src/render/chrome.js +93 -90
- package/dist/src/render/chrome.js.map +1 -1
- package/dist/src/render/layout.d.ts +61 -0
- package/dist/src/render/layout.d.ts.map +1 -0
- package/dist/src/render/layout.js +134 -0
- package/dist/src/render/layout.js.map +1 -0
- package/dist/src/render/project.d.ts +27 -0
- package/dist/src/render/project.d.ts.map +1 -0
- package/dist/src/render/project.js +82 -0
- package/dist/src/render/project.js.map +1 -0
- package/dist/src/render/render-block.d.ts +17 -7
- package/dist/src/render/render-block.d.ts.map +1 -1
- package/dist/src/render/render-block.js +47 -34
- package/dist/src/render/render-block.js.map +1 -1
- package/dist/src/render/term-archive.d.ts +0 -11
- package/dist/src/render/term-archive.d.ts.map +1 -1
- package/dist/src/render/term-archive.js +36 -24
- package/dist/src/render/term-archive.js.map +1 -1
- package/dist/src/render/widget-areas.d.ts +14 -0
- package/dist/src/render/widget-areas.d.ts.map +1 -0
- package/dist/src/render/widget-areas.js +21 -0
- package/dist/src/render/widget-areas.js.map +1 -0
- package/dist/src/render/work.d.ts +37 -0
- package/dist/src/render/work.d.ts.map +1 -0
- package/dist/src/render/work.js +49 -0
- package/dist/src/render/work.js.map +1 -0
- package/dist/theme.config.d.ts +4 -7
- package/dist/theme.config.d.ts.map +1 -1
- package/dist/theme.config.js +6 -9
- package/dist/theme.config.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +10 -1
- package/src/render/blocks/accordion.ts +22 -42
- package/src/render/blocks/collection-list.ts +83 -137
- package/src/render/blocks/cta.ts +24 -12
- package/src/render/blocks/embed.ts +39 -47
- package/src/render/blocks/faq.ts +24 -33
- package/src/render/blocks/feature-grid.ts +38 -41
- package/src/render/blocks/gallery.ts +34 -28
- package/src/render/blocks/hero.ts +31 -28
- package/src/render/blocks/logo-strip.ts +18 -20
- package/src/render/blocks/logos.ts +22 -43
- package/src/render/blocks/media-figure.ts +28 -20
- package/src/render/blocks/pricing-table.ts +31 -32
- package/src/render/blocks/prose.ts +38 -13
- package/src/render/blocks/quote.ts +18 -18
- package/src/render/blocks/stat-counter.ts +19 -37
- package/src/render/blocks/stats.ts +18 -33
- package/src/render/blocks/testimonial.ts +16 -22
- package/src/render/chrome.ts +99 -97
- package/src/render/layout.ts +177 -0
- package/src/render/project.ts +140 -0
- package/src/render/render-block.ts +49 -45
- package/src/render/term-archive.ts +92 -70
- package/src/render/widget-areas.ts +25 -0
- package/src/render/work.ts +124 -0
- package/src/styles/archive.css +61 -21
- package/src/styles/base.css +763 -648
- package/src/styles/blocks.css +697 -1093
- package/src/styles/theme.css +22 -10
- package/src/styles/tokens.css +174 -252
- package/src/styles/widgets.css +948 -0
- package/src/styles/work.css +405 -0
- package/tokens.json +19 -19
|
@@ -1,49 +1,31 @@
|
|
|
1
1
|
import type { StatCounterBlock, StatCounterItem } from '@cogenta/blocks'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
type HtmlElement,
|
|
5
|
-
h,
|
|
6
|
-
heading,
|
|
7
|
-
type RenderContext,
|
|
8
|
-
} from '@cogenta/theme-kit'
|
|
2
|
+
import { type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
import { section, sectionHead } from '../layout.js'
|
|
9
4
|
|
|
10
5
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* its marks. `stats.ts` stays the fuller, boxed "by the numbers" panel.
|
|
6
|
+
* A ruled table of figures from the fourth column, one row per figure: what
|
|
7
|
+
* it counts on the left in the text face, the value on the right in the
|
|
8
|
+
* display width with tabular numerals, so a column of numbers lines up.
|
|
9
|
+
* Where `stats` sets figures side by side, this sets them as a list read
|
|
10
|
+
* down the page. It carries no unit, the narrower shape contract B gives it.
|
|
17
11
|
*/
|
|
18
|
-
function renderItem(item: StatCounterItem
|
|
12
|
+
function renderItem(item: StatCounterItem): HtmlElement {
|
|
19
13
|
return h(
|
|
20
|
-
'
|
|
21
|
-
{ class: 'cg-
|
|
22
|
-
h(
|
|
23
|
-
|
|
24
|
-
{ class: 'cg-counter__index', 'aria-hidden': 'true' },
|
|
25
|
-
String(index + 1).padStart(2, '0'),
|
|
26
|
-
),
|
|
27
|
-
h('span', { class: 'cg-counter__value' }, item.value),
|
|
28
|
-
h('span', { class: 'cg-counter__label' }, item.label),
|
|
14
|
+
'div',
|
|
15
|
+
{ class: 'cg-tally__row' },
|
|
16
|
+
h('dt', { class: 'cg-tally__label' }, item.label),
|
|
17
|
+
h('dd', { class: 'cg-tally__value' }, item.value),
|
|
29
18
|
)
|
|
30
19
|
}
|
|
31
20
|
|
|
32
21
|
export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
|
|
33
|
-
return
|
|
22
|
+
return section(
|
|
34
23
|
'section',
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
block.title,
|
|
42
|
-
),
|
|
43
|
-
h(
|
|
44
|
-
'ul',
|
|
45
|
-
{ class: 'cg-counter__items' },
|
|
46
|
-
block.stats.map((item, index) => renderItem(item, index)),
|
|
47
|
-
),
|
|
24
|
+
'statCounter',
|
|
25
|
+
'cg-tally cg-split',
|
|
26
|
+
{ 'data-titled': block.title === undefined ? 'false' : 'true' },
|
|
27
|
+
'div',
|
|
28
|
+
sectionHead('statCounter', block.title),
|
|
29
|
+
h('dl', { class: 'cg-tally__rows' }, block.stats.map(renderItem)),
|
|
48
30
|
)
|
|
49
31
|
}
|
|
@@ -1,49 +1,34 @@
|
|
|
1
1
|
import type { StatItem, StatsBlock } from '@cogenta/blocks'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
type HtmlElement,
|
|
5
|
-
h,
|
|
6
|
-
heading,
|
|
7
|
-
type RenderContext,
|
|
8
|
-
} from '@cogenta/theme-kit'
|
|
2
|
+
import { type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
import { section, sectionHead } from '../layout.js'
|
|
9
4
|
|
|
10
5
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* The label comes first in the markup and the skin puts the figure above it
|
|
15
|
-
* — reading order and visual order stay independent, which is what WCAG
|
|
16
|
-
* 1.3.2 asks for.
|
|
17
|
-
*
|
|
18
|
-
* Figures are set at the theme's biggest display scale and in tabular mono
|
|
19
|
-
* numerals — a portfolio's "by the numbers" row reads like a spec sheet,
|
|
20
|
-
* not a dashboard widget.
|
|
6
|
+
* Figures side by side, the way a studio states its record: each figure very
|
|
7
|
+
* large in the display width with tabular numerals, its unit after it at the
|
|
8
|
+
* text size, and what it counts under it, a hairline over every column.
|
|
21
9
|
*/
|
|
22
10
|
function renderItem(item: StatItem): HtmlElement {
|
|
23
11
|
return h(
|
|
24
12
|
'div',
|
|
25
|
-
{ class: 'cg-
|
|
26
|
-
h('dt', { class: 'cg-stat__label' }, item.label),
|
|
13
|
+
{ class: 'cg-figures__item' },
|
|
27
14
|
h(
|
|
28
|
-
'
|
|
29
|
-
{ class: 'cg-
|
|
30
|
-
item.value,
|
|
31
|
-
item.unit === undefined ? null : h('span', { class: 'cg-
|
|
15
|
+
'dt',
|
|
16
|
+
{ class: 'cg-figures__figure' },
|
|
17
|
+
h('span', { class: 'cg-figures__value' }, item.value),
|
|
18
|
+
item.unit === undefined ? null : h('span', { class: 'cg-figures__unit' }, item.unit),
|
|
32
19
|
),
|
|
20
|
+
h('dd', { class: 'cg-figures__label' }, item.label),
|
|
33
21
|
)
|
|
34
22
|
}
|
|
35
23
|
|
|
36
24
|
export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
|
|
37
|
-
return
|
|
25
|
+
return section(
|
|
38
26
|
'section',
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
block.title,
|
|
46
|
-
),
|
|
47
|
-
h('dl', { class: 'cg-stats__items' }, block.items.map(renderItem)),
|
|
27
|
+
'stats',
|
|
28
|
+
'cg-figures',
|
|
29
|
+
{ 'data-count': String(Math.min(block.items.length, 4)) },
|
|
30
|
+
'div',
|
|
31
|
+
sectionHead('stats', block.title),
|
|
32
|
+
h('dl', { class: 'cg-figures__items' }, block.items.map(renderItem)),
|
|
48
33
|
)
|
|
49
34
|
}
|
|
@@ -1,44 +1,38 @@
|
|
|
1
1
|
import type { TestimonialBlock } from '@cogenta/blocks'
|
|
2
2
|
import { type HtmlElement, h, image, type RenderContext, renderRichText } from '@cogenta/theme-kit'
|
|
3
|
+
import { section } from '../layout.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* language `feature-grid.ts` uses for its items, at a quieter type scale.
|
|
10
|
-
*
|
|
11
|
-
* `attribution` arrives as one grouped field (RFC 0001's own reasoning for
|
|
12
|
-
* shaping it that way), so its `name`/`role` are not marked `data-field`:
|
|
13
|
-
* that attribute is reserved for a plain-text field the block schema
|
|
14
|
-
* declares at the top level, and neither of these is one — only `attribution`
|
|
15
|
-
* itself is, and it is not plain text.
|
|
16
|
-
*
|
|
17
|
-
* The avatar is decorative, exactly like `quote.ts`'s own — the name sits
|
|
18
|
-
* right beside it in text, so its media entity's alt text is expected empty.
|
|
6
|
+
* A longer word from a client, quieter than `quote`: the words at the lead
|
|
7
|
+
* size from the fourth column, a hairline above them, and the client under
|
|
8
|
+
* them with their role. Where `quote` is a line set large, a testimonial is a
|
|
9
|
+
* paragraph meant to be read.
|
|
19
10
|
*/
|
|
20
11
|
export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
|
|
21
12
|
const { attribution } = block
|
|
22
|
-
return
|
|
13
|
+
return section(
|
|
14
|
+
'div',
|
|
15
|
+
'testimonial',
|
|
16
|
+
'cg-word',
|
|
17
|
+
{},
|
|
23
18
|
'figure',
|
|
24
|
-
{ class: 'cg-
|
|
25
|
-
h('blockquote', { class: 'cg-testimonial__quote' }, renderRichText(ctx, block.quote)),
|
|
19
|
+
h('blockquote', { class: 'cg-word__quote' }, renderRichText(ctx, block.quote)),
|
|
26
20
|
h(
|
|
27
21
|
'figcaption',
|
|
28
|
-
{ class: 'cg-
|
|
22
|
+
{ class: 'cg-word__attribution' },
|
|
29
23
|
attribution.avatar === undefined
|
|
30
24
|
? null
|
|
31
25
|
: image(ctx, attribution.avatar, {
|
|
32
|
-
className: 'cg-
|
|
26
|
+
className: 'cg-word__avatar',
|
|
33
27
|
variant: { width: 96, height: 96, fit: 'cover' },
|
|
34
28
|
}),
|
|
35
29
|
h(
|
|
36
30
|
'span',
|
|
37
|
-
{ class: 'cg-
|
|
38
|
-
h('span', { class: 'cg-
|
|
31
|
+
{ class: 'cg-word__who' },
|
|
32
|
+
h('span', { class: 'cg-word__name' }, attribution.name),
|
|
39
33
|
attribution.role === undefined
|
|
40
34
|
? null
|
|
41
|
-
: h('span', { class: 'cg-
|
|
35
|
+
: h('span', { class: 'cg-word__role' }, attribution.role),
|
|
42
36
|
),
|
|
43
37
|
),
|
|
44
38
|
)
|
package/src/render/chrome.ts
CHANGED
|
@@ -5,138 +5,140 @@ 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'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* The
|
|
15
|
-
* top — the site name treated as a typographic mark rather than a logo
|
|
16
|
-
* slot, a running index number, a real desktop `<nav>`, `headerAction`
|
|
17
|
-
* (`"Let's talk"`, a flat filled button) and a CSS-only mobile menu — and a
|
|
18
|
-
* footer that becomes a second headline: the site name set at display
|
|
19
|
-
* scale, repeated as a closing statement, the `tagline` beneath it, then a
|
|
20
|
-
* real row of footer navigation, social icons (`renderSocialLinks`) and the
|
|
21
|
-
* `footerNote` beside the branding fragment.
|
|
15
|
+
* The header and the footer (`theme@1.4`), as quiet as a studio's own.
|
|
22
16
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* by CSS
|
|
17
|
+
* Header, one row on the twelve columns: the studio's name on the left (or
|
|
18
|
+
* its uploaded logo), the `tagline` in the middle columns on a wide screen,
|
|
19
|
+
* the navigation on the right with the `headerAction` as its last link, and
|
|
20
|
+
* the light/dark control. No bar, no button, no sticky shadow. Below the
|
|
21
|
+
* breakpoint the navigation becomes a full-height panel of large links opened
|
|
22
|
+
* by a CSS-only toggle: a visually hidden checkbox paired with a `<label>`,
|
|
23
|
+
* placed before the `<nav>` so the sibling combinator in `base.css` reaches
|
|
24
|
+
* it. There is exactly one `<nav>` in the markup, retiled by CSS.
|
|
29
25
|
*
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* or not — placed as its own sibling after the nav/hamburger pair so it
|
|
42
|
-
* stays visible whether or not the mobile menu is open, styled in this
|
|
43
|
-
* theme's own hairline-bordered, sharp-cornered register in `base.css`
|
|
44
|
-
* rather than canonical's pill button.
|
|
26
|
+
* Footer, a hairline and three columns: the name, the tagline and the
|
|
27
|
+
* `footerNote`; the footer navigation; the social profiles with their names.
|
|
28
|
+
* Before them, the footer widget columns (`theme@1.6`) when a site has placed
|
|
29
|
+
* any: a tier of their own right under the footer's hairline, on the same
|
|
30
|
+
* twelve columns, so an invitation to get in touch opens the footer the way a
|
|
31
|
+
* studio's own does. Then the legal line: the copyright year and the studio's
|
|
32
|
+
* name beside Cogenta's credit (`brandingHtml`, placed once, as received). No
|
|
33
|
+
* column carries a heading of the theme's own: a heading is a word, and a
|
|
34
|
+
* theme has no translation for it (a widget's title is the site's own words).
|
|
35
|
+
* Every `theme@1.4` field renders only when present, so a host that predates
|
|
36
|
+
* them still gets the name and the navigation.
|
|
45
37
|
*/
|
|
46
38
|
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
const items = links
|
|
39
|
+
function navItems(links: readonly ChromeNavLink[], className: string): string {
|
|
40
|
+
return links
|
|
50
41
|
.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
|
|
51
|
-
.map((link
|
|
42
|
+
.map((link) => {
|
|
52
43
|
const label = escapeText(link.label)
|
|
53
44
|
const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return `<li><span class="cg-nav__index" aria-hidden="true">${number}</span><span${titleAttr}>${label}</span></li>`
|
|
57
|
-
}
|
|
45
|
+
if (link.href === null)
|
|
46
|
+
return `<li class="${className}"><span${titleAttr}>${label}</span></li>`
|
|
58
47
|
const href = escapeAttribute(link.href)
|
|
59
48
|
const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
|
|
60
|
-
return
|
|
61
|
-
`<li><span class="cg-nav__index" aria-hidden="true">${number}</span>` +
|
|
62
|
-
`<a href="${href}"${target}${titleAttr}>${label}</a></li>`
|
|
63
|
-
)
|
|
49
|
+
return `<li class="${className}"><a href="${href}"${target}${titleAttr}>${label}</a></li>`
|
|
64
50
|
})
|
|
65
51
|
.join('')
|
|
66
|
-
return items === '' ? '' : `<ul class="${className}">${items}</ul>`
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** The header's own call-to-action link (`theme@1.4`) — a flat filled button, the theme's one accent-coloured control, at the tail of the nav list. */
|
|
70
|
-
function renderHeaderAction(action: ChromeInput['headerAction']): string {
|
|
71
|
-
if (action === undefined) return ''
|
|
72
|
-
return (
|
|
73
|
-
`<a class="cg-action cg-site-header__action" data-emphasis="primary" ` +
|
|
74
|
-
`href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
|
|
75
|
-
)
|
|
76
52
|
}
|
|
77
53
|
|
|
78
|
-
/**
|
|
79
|
-
function
|
|
80
|
-
|
|
54
|
+
/** `footerNote` as paragraphs: a blank line starts a new one. */
|
|
55
|
+
function renderFooterNote(note: string): string {
|
|
56
|
+
const paragraphs = note
|
|
57
|
+
.replace(/\r\n?/g, '\n')
|
|
58
|
+
.split(/\n\s*\n/)
|
|
59
|
+
.map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ').trim())
|
|
60
|
+
.filter((paragraph) => paragraph !== '')
|
|
61
|
+
if (paragraphs.length === 0) return ''
|
|
62
|
+
return `<div class="cg-colophon__note">${paragraphs
|
|
63
|
+
.map((paragraph) => `<p>${escapeText(paragraph)}</p>`)
|
|
64
|
+
.join('')}</div>`
|
|
81
65
|
}
|
|
82
66
|
|
|
83
67
|
export function renderChrome(input: ChromeInput): ChromeResult {
|
|
84
68
|
const siteName = escapeText(input.site.name)
|
|
85
69
|
const home = escapeAttribute(input.homeHref)
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const logo = renderBrandMark(input.brand, { className: 'cg-
|
|
95
|
-
const mark = logo ?? `<span class="cg-
|
|
70
|
+
const links = navItems(input.headerNav, 'cg-masthead__item')
|
|
71
|
+
const action =
|
|
72
|
+
input.headerAction === undefined
|
|
73
|
+
? ''
|
|
74
|
+
: `<a class="cg-masthead__action" href="${escapeAttribute(input.headerAction.href)}">${escapeText(
|
|
75
|
+
input.headerAction.label,
|
|
76
|
+
)}</a>`
|
|
77
|
+
const toggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
|
|
78
|
+
const logo = renderBrandMark(input.brand, { className: 'cg-masthead__logo' })
|
|
79
|
+
const mark = logo ?? `<span class="cg-masthead__wordmark">${siteName}</span>`
|
|
80
|
+
const tagline =
|
|
81
|
+
input.tagline === undefined
|
|
82
|
+
? ''
|
|
83
|
+
: `<p class="cg-masthead__tagline">${escapeText(input.tagline)}</p>`
|
|
96
84
|
|
|
97
|
-
const hasMenu =
|
|
98
|
-
const
|
|
99
|
-
? `<nav class="cg-site-header__nav" id="cg-nav" aria-label="Primary">${headerNav}${headerAction}</nav>`
|
|
100
|
-
: ''
|
|
101
|
-
const toggle = hasMenu
|
|
85
|
+
const hasMenu = links !== '' || action !== ''
|
|
86
|
+
const menu = hasMenu
|
|
102
87
|
? `<input type="checkbox" id="cg-nav-toggle" class="cg-nav-toggle-input" aria-label="Menu">` +
|
|
103
|
-
`<label for="cg-nav-toggle" class="cg-nav-toggle-label" aria-hidden="true"
|
|
88
|
+
`<label for="cg-nav-toggle" class="cg-nav-toggle-label" aria-hidden="true">` +
|
|
89
|
+
`<span class="cg-nav-toggle-bar"></span><span class="cg-nav-toggle-bar"></span>` +
|
|
90
|
+
`</label>` +
|
|
91
|
+
`<nav class="cg-masthead__nav" id="cg-nav" aria-label="Primary">` +
|
|
92
|
+
`${links === '' ? '' : `<ul class="cg-masthead__links">${links}</ul>`}` +
|
|
93
|
+
`${action}` +
|
|
94
|
+
`</nav>`
|
|
104
95
|
: ''
|
|
105
96
|
|
|
106
97
|
const header =
|
|
107
|
-
`<header class="cg-
|
|
108
|
-
`<
|
|
109
|
-
|
|
110
|
-
`${
|
|
98
|
+
`<header class="cg-masthead" data-nav="${hasMenu ? 'links' : 'none'}">` +
|
|
99
|
+
`<div class="cg-masthead__inner">` +
|
|
100
|
+
`<a class="cg-masthead__home" href="${home}">${mark}</a>` +
|
|
101
|
+
`${tagline}` +
|
|
102
|
+
`${menu}` +
|
|
103
|
+
`${toggle}` +
|
|
111
104
|
`</div></header>`
|
|
112
105
|
|
|
113
|
-
const
|
|
106
|
+
const footerTagline =
|
|
114
107
|
input.tagline === undefined
|
|
115
108
|
? ''
|
|
116
|
-
: `<p class="cg-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
109
|
+
: `<p class="cg-colophon__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
|
|
110
|
+
const note = input.footerNote === undefined ? '' : renderFooterNote(input.footerNote)
|
|
111
|
+
const footerLinks = navItems(input.footerNav, 'cg-colophon__item')
|
|
112
|
+
const social = renderSocialLinks(input.social, {
|
|
113
|
+
className: 'cg-colophon__social',
|
|
114
|
+
itemClassName: 'cg-colophon__social-item',
|
|
115
|
+
})
|
|
116
|
+
const year = new Date().getFullYear()
|
|
117
|
+
// The footer widget columns (`theme@1.6`): a tier above the name, the pages
|
|
118
|
+
// and the profiles, under the footer's hairline, on the footer's own grid.
|
|
119
|
+
const footerWidgets = renderFooterWidgets(input.widgets, {
|
|
120
|
+
className: 'cg-colophon__widgets',
|
|
121
|
+
headingLevel: 'h2',
|
|
122
|
+
})
|
|
130
123
|
|
|
131
124
|
const footer =
|
|
132
|
-
`<footer class="cg-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
`<
|
|
136
|
-
`${
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
125
|
+
`<footer class="cg-colophon"><div class="cg-colophon__inner">` +
|
|
126
|
+
`${footerWidgets === null ? '' : serialize(footerWidgets)}` +
|
|
127
|
+
`<div class="cg-colophon__about">` +
|
|
128
|
+
`<a class="cg-colophon__name" href="${home}">${siteName}</a>` +
|
|
129
|
+
`${footerTagline}${note}` +
|
|
130
|
+
`</div>` +
|
|
131
|
+
`${
|
|
132
|
+
footerLinks === ''
|
|
133
|
+
? ''
|
|
134
|
+
: `<nav class="cg-colophon__nav" aria-label="Footer"><ul class="cg-colophon__links">${footerLinks}</ul></nav>`
|
|
135
|
+
}` +
|
|
136
|
+
`${social === null ? '' : `<div class="cg-colophon__follow">${serialize(social)}</div>`}` +
|
|
137
|
+
`<div class="cg-colophon__legal">` +
|
|
138
|
+
`<p class="cg-colophon__copyright">© ${year} ${siteName}</p>` +
|
|
139
|
+
`<div class="cg-colophon__branding">${input.brandingHtml}</div>` +
|
|
140
|
+
`</div>` +
|
|
141
|
+
`</div></footer>`
|
|
140
142
|
|
|
141
143
|
return { header, footer }
|
|
142
144
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Attributes,
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type Child,
|
|
5
|
+
type ContentEntry,
|
|
6
|
+
entryDate,
|
|
7
|
+
type HtmlElement,
|
|
8
|
+
h,
|
|
9
|
+
heading,
|
|
10
|
+
} from '@cogenta/theme-kit'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The frame every block of this theme renders into, and the small pieces of
|
|
14
|
+
* typography the blocks share.
|
|
15
|
+
*
|
|
16
|
+
* The outer element carries the one section rhythm (`--cg-section`, the same
|
|
17
|
+
* for every block) and paints edge to edge when a variant asks for a band.
|
|
18
|
+
* The inner element is the twelve-column container, so the left edge of a
|
|
19
|
+
* statement, a project image, a caption and a list of services is always the
|
|
20
|
+
* same line.
|
|
21
|
+
*
|
|
22
|
+
* `inner` is `figure` for a block whose semantics are a figure (a quotation
|
|
23
|
+
* and its attribution, an image and its caption): `<figcaption>` must be a
|
|
24
|
+
* direct child of its `<figure>`.
|
|
25
|
+
*/
|
|
26
|
+
export function section(
|
|
27
|
+
outer: 'section' | 'div',
|
|
28
|
+
block: string,
|
|
29
|
+
className: string,
|
|
30
|
+
attrs: Attributes,
|
|
31
|
+
inner: 'div' | 'figure',
|
|
32
|
+
...children: readonly Child[]
|
|
33
|
+
): HtmlElement {
|
|
34
|
+
return h(
|
|
35
|
+
outer,
|
|
36
|
+
{ class: `cg-section ${className}`, 'data-block': block, ...attrs },
|
|
37
|
+
h(inner, { class: `cg-container ${className}__inner` }, ...children),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The label of a titled block: a hairline across the whole container and the
|
|
43
|
+
* title under it, small, in the text face. A studio lets the work carry the
|
|
44
|
+
* page, so a section says what it is and gets out of the way. On a wide
|
|
45
|
+
* screen the label sits in the first three columns and the content starts at
|
|
46
|
+
* the fourth (`.cg-split`), or it runs above a full-width grid.
|
|
47
|
+
*/
|
|
48
|
+
export function sectionHead(blockName: string, title: string | undefined): HtmlElement | null {
|
|
49
|
+
if (title === undefined) return null
|
|
50
|
+
return h(
|
|
51
|
+
'div',
|
|
52
|
+
{ class: 'cg-head' },
|
|
53
|
+
heading(
|
|
54
|
+
blockHeadingTag(blockName) ?? 'h2',
|
|
55
|
+
{ class: 'cg-head__title', 'data-field': 'title' },
|
|
56
|
+
title,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A taxonomy field stores a term id, never a label: a value shaped like one
|
|
63
|
+
* is not something a visitor can be shown.
|
|
64
|
+
*/
|
|
65
|
+
const TERM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
66
|
+
|
|
67
|
+
function plainText(entry: ContentEntry, fields: readonly string[]): string | undefined {
|
|
68
|
+
for (const field of fields) {
|
|
69
|
+
const value = entry[field]
|
|
70
|
+
if (typeof value === 'string' && value.trim() !== '' && !TERM_ID.test(value.trim())) {
|
|
71
|
+
return value.trim()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const CLIENT_FIELDS = ['client', 'clientName', 'customer', 'organisation', 'organization'] as const
|
|
78
|
+
const DISCIPLINE_FIELDS = ['discipline', 'role', 'service', 'category', 'kicker'] as const
|
|
79
|
+
|
|
80
|
+
/** A four-digit year, from a `year` field when the collection keeps one, or from the entry's date. */
|
|
81
|
+
export function entryYear(entry: ContentEntry): string | undefined {
|
|
82
|
+
const declared = entry.year
|
|
83
|
+
if (typeof declared === 'string' && /^\d{4}$/.test(declared.trim())) return declared.trim()
|
|
84
|
+
if (typeof declared === 'number' && Number.isInteger(declared)) return String(declared)
|
|
85
|
+
const iso = entryDate(entry)
|
|
86
|
+
return iso === undefined ? undefined : yearOf(iso)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `2025` from an ISO 8601 date, or `undefined` when the value is not one. */
|
|
90
|
+
export function yearOf(iso: string): string | undefined {
|
|
91
|
+
const date = new Date(iso)
|
|
92
|
+
return Number.isNaN(date.getTime()) ? undefined : String(date.getUTCFullYear())
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The caption line a studio sets under a piece of work: client, discipline,
|
|
97
|
+
* year, in that order, each only when the entry carries it. Read from the
|
|
98
|
+
* plain-text fields a collection declares, never from a taxonomy id and never
|
|
99
|
+
* invented.
|
|
100
|
+
*/
|
|
101
|
+
export interface WorkCaption {
|
|
102
|
+
readonly client?: string
|
|
103
|
+
readonly discipline?: string
|
|
104
|
+
readonly year?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function entryCaption(entry: ContentEntry): WorkCaption {
|
|
108
|
+
const client = plainText(entry, CLIENT_FIELDS)
|
|
109
|
+
const discipline = plainText(entry, DISCIPLINE_FIELDS)
|
|
110
|
+
const year = entryYear(entry)
|
|
111
|
+
return {
|
|
112
|
+
...(client === undefined ? {} : { client }),
|
|
113
|
+
...(discipline === undefined ? {} : { discipline }),
|
|
114
|
+
...(year === undefined ? {} : { year }),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A caption never repeats the title it sits under: a project named after its
|
|
120
|
+
* client ("Fenmore Building Society") keeps the discipline and the year only.
|
|
121
|
+
* The index, where the client has a column of its own, keeps it.
|
|
122
|
+
*/
|
|
123
|
+
export function captionUnder(caption: WorkCaption, title: string): WorkCaption {
|
|
124
|
+
if (caption.client === undefined) return caption
|
|
125
|
+
if (!title.toLocaleLowerCase().includes(caption.client.toLocaleLowerCase())) return caption
|
|
126
|
+
const { client: _repeated, ...rest } = caption
|
|
127
|
+
return rest
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The caption as markup: one paragraph, one span per part, the separators drawn by the stylesheet. */
|
|
131
|
+
export function renderCaption(caption: WorkCaption, className: string): HtmlElement | null {
|
|
132
|
+
const parts = [
|
|
133
|
+
caption.client === undefined
|
|
134
|
+
? null
|
|
135
|
+
: h('span', { class: `${className}-part`, 'data-part': 'client' }, caption.client),
|
|
136
|
+
caption.discipline === undefined
|
|
137
|
+
? null
|
|
138
|
+
: h('span', { class: `${className}-part`, 'data-part': 'discipline' }, caption.discipline),
|
|
139
|
+
caption.year === undefined
|
|
140
|
+
? null
|
|
141
|
+
: h('span', { class: `${className}-part`, 'data-part': 'year' }, caption.year),
|
|
142
|
+
].filter((part): part is HtmlElement => part !== null)
|
|
143
|
+
if (parts.length === 0) return null
|
|
144
|
+
return h('p', { class: className }, parts)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A taxonomy's own name, as a label: `disciplines` becomes `Disciplines`,
|
|
149
|
+
* `project_team` becomes `Project team`. The name is the one the site owner
|
|
150
|
+
* gave the taxonomy; this only sets it in sentence case.
|
|
151
|
+
*/
|
|
152
|
+
export function taxonomyLabel(name: string, locale: string): string {
|
|
153
|
+
const words = name
|
|
154
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
155
|
+
.replace(/[_-]+/g, ' ')
|
|
156
|
+
.trim()
|
|
157
|
+
.toLowerCase()
|
|
158
|
+
if (words === '') return name
|
|
159
|
+
return words.charAt(0).toLocaleUpperCase(locale) + words.slice(1)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The word for "year" in the page's own language, from the platform's own
|
|
164
|
+
* data (`Intl.DisplayNames`), so the fact sheet of a project page labels its
|
|
165
|
+
* date without a translation table this theme would have to carry.
|
|
166
|
+
*/
|
|
167
|
+
export function yearLabel(locale: string): string {
|
|
168
|
+
try {
|
|
169
|
+
const name = new Intl.DisplayNames([locale], { type: 'dateTimeField' }).of('year')
|
|
170
|
+
if (name !== undefined && name !== '') {
|
|
171
|
+
return name.charAt(0).toLocaleUpperCase(locale) + name.slice(1)
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// An invalid locale tag: fall through to the English field name.
|
|
175
|
+
}
|
|
176
|
+
return 'Year'
|
|
177
|
+
}
|