@cogenta/theme-restaurant 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +25 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/render/blocks/accordion.d.ts +4 -0
- package/dist/src/render/blocks/accordion.d.ts.map +1 -0
- package/dist/src/render/blocks/accordion.js +21 -0
- package/dist/src/render/blocks/accordion.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +13 -0
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
- package/dist/src/render/blocks/collection-list.js +109 -0
- package/dist/src/render/blocks/collection-list.js.map +1 -0
- package/dist/src/render/blocks/cta.d.ts +10 -0
- package/dist/src/render/blocks/cta.d.ts.map +1 -0
- package/dist/src/render/blocks/cta.js +13 -0
- package/dist/src/render/blocks/cta.js.map +1 -0
- package/dist/src/render/blocks/embed.d.ts +4 -0
- package/dist/src/render/blocks/embed.d.ts.map +1 -0
- package/dist/src/render/blocks/embed.js +82 -0
- package/dist/src/render/blocks/embed.js.map +1 -0
- package/dist/src/render/blocks/faq.d.ts +4 -0
- package/dist/src/render/blocks/faq.d.ts.map +1 -0
- package/dist/src/render/blocks/faq.js +17 -0
- package/dist/src/render/blocks/faq.js.map +1 -0
- package/dist/src/render/blocks/feature-grid.d.ts +4 -0
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
- package/dist/src/render/blocks/feature-grid.js +24 -0
- package/dist/src/render/blocks/feature-grid.js.map +1 -0
- package/dist/src/render/blocks/gallery.d.ts +11 -0
- package/dist/src/render/blocks/gallery.d.ts.map +1 -0
- package/dist/src/render/blocks/gallery.js +20 -0
- package/dist/src/render/blocks/gallery.js.map +1 -0
- package/dist/src/render/blocks/hero.d.ts +16 -0
- package/dist/src/render/blocks/hero.d.ts.map +1 -0
- package/dist/src/render/blocks/hero.js +32 -0
- package/dist/src/render/blocks/hero.js.map +1 -0
- package/dist/src/render/blocks/logo-strip.d.ts +4 -0
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
- package/dist/src/render/blocks/logo-strip.js +16 -0
- package/dist/src/render/blocks/logo-strip.js.map +1 -0
- package/dist/src/render/blocks/logos.d.ts +4 -0
- package/dist/src/render/blocks/logos.d.ts.map +1 -0
- package/dist/src/render/blocks/logos.js +23 -0
- package/dist/src/render/blocks/logos.js.map +1 -0
- package/dist/src/render/blocks/media-figure.d.ts +9 -0
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
- package/dist/src/render/blocks/media-figure.js +24 -0
- package/dist/src/render/blocks/media-figure.js.map +1 -0
- package/dist/src/render/blocks/pricing-table.d.ts +4 -0
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
- package/dist/src/render/blocks/pricing-table.js +30 -0
- package/dist/src/render/blocks/pricing-table.js.map +1 -0
- package/dist/src/render/blocks/prose.d.ts +13 -0
- package/dist/src/render/blocks/prose.d.ts.map +1 -0
- package/dist/src/render/blocks/prose.js +14 -0
- package/dist/src/render/blocks/prose.js.map +1 -0
- package/dist/src/render/blocks/quote.d.ts +8 -0
- package/dist/src/render/blocks/quote.d.ts.map +1 -0
- package/dist/src/render/blocks/quote.js +21 -0
- package/dist/src/render/blocks/quote.js.map +1 -0
- package/dist/src/render/blocks/stat-counter.d.ts +4 -0
- package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
- package/dist/src/render/blocks/stat-counter.js +15 -0
- package/dist/src/render/blocks/stat-counter.js.map +1 -0
- package/dist/src/render/blocks/stats.d.ts +4 -0
- package/dist/src/render/blocks/stats.d.ts.map +1 -0
- package/dist/src/render/blocks/stats.js +21 -0
- package/dist/src/render/blocks/stats.js.map +1 -0
- package/dist/src/render/blocks/testimonial.d.ts +17 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +26 -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 +108 -0
- package/dist/src/render/chrome.js.map +1 -0
- package/dist/src/render/render-block.d.ts +6 -0
- package/dist/src/render/render-block.d.ts.map +1 -0
- package/dist/src/render/render-block.js +118 -0
- package/dist/src/render/render-block.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +10 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +32 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/theme.config.d.ts +11 -0
- package/dist/theme.config.d.ts.map +1 -0
- package/dist/theme.config.js +41 -0
- package/dist/theme.config.js.map +1 -0
- package/package.json +57 -0
- package/src/index.ts +24 -0
- package/src/render/blocks/accordion.ts +57 -0
- package/src/render/blocks/collection-list.ts +189 -0
- package/src/render/blocks/cta.ts +31 -0
- package/src/render/blocks/embed.ts +105 -0
- package/src/render/blocks/faq.ts +53 -0
- package/src/render/blocks/feature-grid.ts +61 -0
- package/src/render/blocks/gallery.ts +40 -0
- package/src/render/blocks/hero.ts +58 -0
- package/src/render/blocks/logo-strip.ts +35 -0
- package/src/render/blocks/logos.ts +53 -0
- package/src/render/blocks/media-figure.ts +39 -0
- package/src/render/blocks/pricing-table.ts +74 -0
- package/src/render/blocks/prose.ts +19 -0
- package/src/render/blocks/quote.ts +38 -0
- package/src/render/blocks/stat-counter.ts +37 -0
- package/src/render/blocks/stats.ts +48 -0
- package/src/render/blocks/testimonial.ts +42 -0
- package/src/render/chrome.ts +141 -0
- package/src/render/render-block.ts +156 -0
- package/src/render/term-archive.ts +78 -0
- package/src/styles/archive.css +84 -0
- package/src/styles/base.css +923 -0
- package/src/styles/blocks.css +975 -0
- package/src/styles/theme.css +36 -0
- package/src/styles/tokens.css +320 -0
- package/tokens.json +36 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PricingTableBlock, PricingTier } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
actionLink,
|
|
4
|
+
blockHeadingTag,
|
|
5
|
+
type HeadingTag,
|
|
6
|
+
type HtmlElement,
|
|
7
|
+
h,
|
|
8
|
+
heading,
|
|
9
|
+
nestedHeadingTag,
|
|
10
|
+
type RenderContext,
|
|
11
|
+
} from '@cogenta/theme-kit'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `blocks@2.0` (RFC 0001). Rendered as a set-menu comparison — a "tasting
|
|
15
|
+
* menu" / "prix fixe" tier is the shape this block already has (a name, a
|
|
16
|
+
* price, a feature list), so this theme reuses the same restrained bordered
|
|
17
|
+
* plates the rest of the vocabulary uses rather than a distinct pricing
|
|
18
|
+
* skin. `highlighted` becomes a small "chef's pick" rule rather than a
|
|
19
|
+
* colour.
|
|
20
|
+
*/
|
|
21
|
+
function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
|
|
22
|
+
return h(
|
|
23
|
+
'li',
|
|
24
|
+
{
|
|
25
|
+
class: 'cg-set-menu__tier',
|
|
26
|
+
'data-highlighted': tier.highlighted === true ? 'true' : undefined,
|
|
27
|
+
'aria-current': tier.highlighted === true ? 'true' : undefined,
|
|
28
|
+
},
|
|
29
|
+
heading(tag, { class: 'cg-set-menu__name' }, tier.name),
|
|
30
|
+
h(
|
|
31
|
+
'p',
|
|
32
|
+
{ class: 'cg-set-menu__price' },
|
|
33
|
+
h('span', { class: 'cg-set-menu__amount' }, tier.price),
|
|
34
|
+
tier.interval === undefined
|
|
35
|
+
? null
|
|
36
|
+
: h('span', { class: 'cg-set-menu__interval' }, tier.interval),
|
|
37
|
+
),
|
|
38
|
+
tier.features.length === 0
|
|
39
|
+
? null
|
|
40
|
+
: h(
|
|
41
|
+
'ul',
|
|
42
|
+
{ class: 'cg-set-menu__features' },
|
|
43
|
+
tier.features.map((feature) => h('li', { class: 'cg-set-menu__feature' }, feature)),
|
|
44
|
+
),
|
|
45
|
+
tier.action === undefined
|
|
46
|
+
? null
|
|
47
|
+
: h(
|
|
48
|
+
'div',
|
|
49
|
+
{ class: 'cg-set-menu__action' },
|
|
50
|
+
actionLink(ctx, { ...tier.action, emphasis: tier.action.emphasis ?? 'primary' }),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
|
|
56
|
+
const hasTitle = block.title !== undefined
|
|
57
|
+
const tierTag = nestedHeadingTag('pricingTable', hasTitle)
|
|
58
|
+
return h(
|
|
59
|
+
'section',
|
|
60
|
+
{ class: 'cg-set-menu', 'data-block': 'pricingTable' },
|
|
61
|
+
hasTitle
|
|
62
|
+
? heading(
|
|
63
|
+
blockHeadingTag('pricingTable') ?? 'h2',
|
|
64
|
+
{ class: 'cg-set-menu__title', 'data-field': 'title' },
|
|
65
|
+
block.title ?? '',
|
|
66
|
+
)
|
|
67
|
+
: null,
|
|
68
|
+
h(
|
|
69
|
+
'ul',
|
|
70
|
+
{ class: 'cg-set-menu__tiers' },
|
|
71
|
+
block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ProseBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, type RenderContext, renderRichText } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "Our story" — a short, centred, narrow column (`--cg-measure-narrow` in
|
|
6
|
+
* `tokens.css`), the quiet editorial interlude every one of the reference
|
|
7
|
+
* templates (Divi/Astra "Restaurant") places between the hero and the menu.
|
|
8
|
+
*
|
|
9
|
+
* `prose` declares `headingLevel: 'none'`: it contributes no heading of its
|
|
10
|
+
* own. Whatever headings appear come from the rich text document, whose
|
|
11
|
+
* vocabulary starts at `h2`.
|
|
12
|
+
*/
|
|
13
|
+
export function renderProse(block: ProseBlock, ctx: RenderContext): HtmlElement {
|
|
14
|
+
return h(
|
|
15
|
+
'div',
|
|
16
|
+
{ class: 'cg-story', 'data-block': 'prose' },
|
|
17
|
+
h('div', { class: 'cg-story__body' }, renderRichText(ctx, block.body)),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { QuoteBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single centred line, set in the display serif — the "menu insert"
|
|
6
|
+
* pull-quote, distinct from `testimonial`'s guestbook card.
|
|
7
|
+
*/
|
|
8
|
+
export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement {
|
|
9
|
+
const hasAttribution =
|
|
10
|
+
block.author !== undefined || block.role !== undefined || block.avatar !== undefined
|
|
11
|
+
return h(
|
|
12
|
+
'figure',
|
|
13
|
+
{ class: 'cg-line', 'data-block': 'quote' },
|
|
14
|
+
h('blockquote', { class: 'cg-line__text' }, h('p', { 'data-field': 'text' }, block.text)),
|
|
15
|
+
hasAttribution
|
|
16
|
+
? h(
|
|
17
|
+
'figcaption',
|
|
18
|
+
{ class: 'cg-line__attribution' },
|
|
19
|
+
block.avatar === undefined
|
|
20
|
+
? null
|
|
21
|
+
: image(ctx, block.avatar, {
|
|
22
|
+
className: 'cg-line__avatar',
|
|
23
|
+
variant: { width: 72, height: 72, fit: 'cover' },
|
|
24
|
+
}),
|
|
25
|
+
h(
|
|
26
|
+
'span',
|
|
27
|
+
{ class: 'cg-line__who' },
|
|
28
|
+
block.author === undefined
|
|
29
|
+
? null
|
|
30
|
+
: h('span', { class: 'cg-line__author', 'data-field': 'author' }, block.author),
|
|
31
|
+
block.role === undefined
|
|
32
|
+
? null
|
|
33
|
+
: h('span', { class: 'cg-line__role', 'data-field': 'role' }, block.role),
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
: null,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { StatCounterBlock, StatCounterItem } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
type RenderContext,
|
|
8
|
+
} from '@cogenta/theme-kit'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `blocks@2.0` (RFC 0001). Narrower than `stats` (no `unit`) — rendered the
|
|
12
|
+
* same restrained "figures row" way as `stats`, one accent hairline apart:
|
|
13
|
+
* this theme has no boastful, boxed "impact numbers" register to reach for.
|
|
14
|
+
*/
|
|
15
|
+
function renderItem(item: StatCounterItem): HtmlElement {
|
|
16
|
+
return h(
|
|
17
|
+
'div',
|
|
18
|
+
{ class: 'cg-tally' },
|
|
19
|
+
h('dd', { class: 'cg-tally__value' }, item.value),
|
|
20
|
+
h('dt', { class: 'cg-tally__label' }, item.label),
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
|
|
25
|
+
return h(
|
|
26
|
+
'section',
|
|
27
|
+
{ class: 'cg-tallies', 'data-block': 'statCounter' },
|
|
28
|
+
block.title === undefined
|
|
29
|
+
? null
|
|
30
|
+
: heading(
|
|
31
|
+
blockHeadingTag('statCounter') ?? 'h2',
|
|
32
|
+
{ class: 'cg-tallies__title', 'data-field': 'title' },
|
|
33
|
+
block.title,
|
|
34
|
+
),
|
|
35
|
+
h('dl', { class: 'cg-tallies__items' }, block.stats.map(renderItem)),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { StatItem, StatsBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
blockHeadingTag,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
heading,
|
|
7
|
+
type RenderContext,
|
|
8
|
+
} from '@cogenta/theme-kit'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "Since 1994 · 3 chefs · 120 seats" — a quiet row of figures set in the
|
|
12
|
+
* display serif, separated by hairline rules rather than boxed into cards:
|
|
13
|
+
* the restrained, editorial register this theme uses everywhere a fact is
|
|
14
|
+
* stated rather than sold.
|
|
15
|
+
*
|
|
16
|
+
* A description list, as every built-in theme uses for `stats`: each figure
|
|
17
|
+
* is the description of its label, which is what `<dt>`/`<dd>` mean, and it
|
|
18
|
+
* survives being read linearly (WCAG 1.3.2 — reading order and visual order
|
|
19
|
+
* stay independent regardless of how the skin repaints them).
|
|
20
|
+
*/
|
|
21
|
+
function renderItem(item: StatItem): HtmlElement {
|
|
22
|
+
return h(
|
|
23
|
+
'div',
|
|
24
|
+
{ class: 'cg-figures__item' },
|
|
25
|
+
h(
|
|
26
|
+
'dd',
|
|
27
|
+
{ class: 'cg-figures__value' },
|
|
28
|
+
item.value,
|
|
29
|
+
item.unit === undefined ? null : h('span', { class: 'cg-figures__unit' }, item.unit),
|
|
30
|
+
),
|
|
31
|
+
h('dt', { class: 'cg-figures__label' }, item.label),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
|
|
36
|
+
return h(
|
|
37
|
+
'section',
|
|
38
|
+
{ class: 'cg-figures', 'data-block': 'stats' },
|
|
39
|
+
block.title === undefined
|
|
40
|
+
? null
|
|
41
|
+
: heading(
|
|
42
|
+
blockHeadingTag('stats') ?? 'h2',
|
|
43
|
+
{ class: 'cg-figures__title', 'data-field': 'title' },
|
|
44
|
+
block.title,
|
|
45
|
+
),
|
|
46
|
+
h('dl', { class: 'cg-figures__items' }, block.items.map(renderItem)),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TestimonialBlock } from '@cogenta/blocks'
|
|
2
|
+
import { type HtmlElement, h, image, type RenderContext, renderRichText } from '@cogenta/theme-kit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A centred, oversized quotation mark stands above the quote (a CSS
|
|
6
|
+
* pseudo-glyph, not markup) — the guestbook-page treatment this theme's
|
|
7
|
+
* elegance calls for, rather than a bordered "client success" card.
|
|
8
|
+
*
|
|
9
|
+
* `<figure><blockquote>…</blockquote><figcaption>` is the attribution
|
|
10
|
+
* pattern the HTML spec prescribes: putting the author inside the
|
|
11
|
+
* `<blockquote>` would claim the author's name was part of what was said.
|
|
12
|
+
*
|
|
13
|
+
* The avatar is decorative — the name sits right beside it in text — so its
|
|
14
|
+
* media entity's alt text is expected to be empty; `image` still writes the
|
|
15
|
+
* attribute either way (WCAG 1.1.1).
|
|
16
|
+
*/
|
|
17
|
+
export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
|
|
18
|
+
const { attribution } = block
|
|
19
|
+
return h(
|
|
20
|
+
'figure',
|
|
21
|
+
{ class: 'cg-testimonial', 'data-block': 'testimonial' },
|
|
22
|
+
h('blockquote', { class: 'cg-testimonial__quote' }, renderRichText(ctx, block.quote)),
|
|
23
|
+
h(
|
|
24
|
+
'figcaption',
|
|
25
|
+
{ class: 'cg-testimonial__attribution' },
|
|
26
|
+
attribution.avatar === undefined
|
|
27
|
+
? null
|
|
28
|
+
: image(ctx, attribution.avatar, {
|
|
29
|
+
className: 'cg-testimonial__avatar',
|
|
30
|
+
variant: { width: 88, height: 88, fit: 'cover' },
|
|
31
|
+
}),
|
|
32
|
+
h(
|
|
33
|
+
'span',
|
|
34
|
+
{ class: 'cg-testimonial__who' },
|
|
35
|
+
h('span', { class: 'cg-testimonial__name' }, attribution.name),
|
|
36
|
+
attribution.role === undefined
|
|
37
|
+
? null
|
|
38
|
+
: h('span', { class: 'cg-testimonial__role' }, attribution.role),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
* This theme's own header and footer, built for `theme@1.4` from the start
|
|
15
|
+
* rather than retrofitted: `headerAction` renders as a button beside the
|
|
16
|
+
* primary nav, `tagline`/`social`/`footerNote` fill the footer's brand
|
|
17
|
+
* column, and — the one thing none of the four L23 themes had yet — a
|
|
18
|
+
* genuinely CSS-only mobile menu.
|
|
19
|
+
*
|
|
20
|
+
* The mobile menu is a `<details>`/`<summary>` disclosure, not a checkbox
|
|
21
|
+
* hack: expanding, keyboard operation (`Enter`/`Space` on the `<summary>`)
|
|
22
|
+
* and the open/closed state announced to assistive technology all come from
|
|
23
|
+
* the browser, at zero bytes of JavaScript. It carries the *same* links as
|
|
24
|
+
* the desktop nav plus the header action — never a second, drifting list —
|
|
25
|
+
* and is shown only below the breakpoint `base.css` sets for `.cg-nav`.
|
|
26
|
+
*
|
|
27
|
+
* The manual light/dark toggle (L26) sits right after the desktop nav, before
|
|
28
|
+
* the header action — `renderThemeToggle` (`@cogenta/theme-kit`) returns
|
|
29
|
+
* markup only, this theme styles `.cg-theme-toggle` itself in `base.css`. It
|
|
30
|
+
* is unconditional (rendered on every page, `theme@1.4` fields or not),
|
|
31
|
+
* unlike the header/footer additions above it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
function navItems(links: readonly ChromeNavLink[]): string {
|
|
35
|
+
return links
|
|
36
|
+
.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
|
|
37
|
+
.map((link) => {
|
|
38
|
+
const label = escapeText(link.label)
|
|
39
|
+
const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
|
|
40
|
+
if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
|
|
41
|
+
const href = escapeAttribute(link.href)
|
|
42
|
+
const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
|
|
43
|
+
return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
|
|
44
|
+
})
|
|
45
|
+
.join('')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Three bars, drawn directly rather than pulled from an icon font (R9/R10) — the universal "menu" glyph. */
|
|
49
|
+
const HAMBURGER_GLYPH =
|
|
50
|
+
'<svg class="cg-mobile-nav__glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
|
51
|
+
'stroke-width="1.5" stroke-linecap="round" aria-hidden="true" focusable="false">' +
|
|
52
|
+
'<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>'
|
|
53
|
+
|
|
54
|
+
/** The header's own call-to-action link (`theme@1.4`), styled like the block vocabulary's own primary action. */
|
|
55
|
+
function renderHeaderAction(action: ChromeInput['headerAction']): string {
|
|
56
|
+
if (action === undefined) return ''
|
|
57
|
+
return (
|
|
58
|
+
`<a class="cg-action cg-nav__cta" data-emphasis="primary" ` +
|
|
59
|
+
`href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The mobile drawer — the same nav links and header action as the desktop
|
|
65
|
+
* header, in one `<details>` disclosure. Absent entirely when there is
|
|
66
|
+
* nothing to show, exactly like the desktop `<nav>` above it.
|
|
67
|
+
*/
|
|
68
|
+
function renderMobileNav(input: ChromeInput): string {
|
|
69
|
+
const items = navItems(input.headerNav)
|
|
70
|
+
const actionItem =
|
|
71
|
+
input.headerAction === undefined ? '' : `<li>${renderHeaderAction(input.headerAction)}</li>`
|
|
72
|
+
if (items === '' && actionItem === '') return ''
|
|
73
|
+
return (
|
|
74
|
+
`<details class="cg-mobile-nav">` +
|
|
75
|
+
`<summary class="cg-mobile-nav__toggle" aria-label="Menu">${HAMBURGER_GLYPH}</summary>` +
|
|
76
|
+
`<div class="cg-mobile-nav__panel"><ul class="cg-mobile-nav__items">${items}${actionItem}</ul></div>` +
|
|
77
|
+
`</details>`
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderChrome(input: ChromeInput): ChromeResult {
|
|
82
|
+
const siteNameText = escapeText(input.site.name)
|
|
83
|
+
const headerNav = navItems(input.headerNav)
|
|
84
|
+
const footerNav = navItems(input.footerNav)
|
|
85
|
+
// The uploaded logo replaces the wordmark, and only the wordmark: the
|
|
86
|
+
// footer keeps the site's name in text, so a site whose logo fails to
|
|
87
|
+
// load is still named somewhere on every page.
|
|
88
|
+
const mark = renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ?? siteNameText
|
|
89
|
+
const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
|
|
90
|
+
const headerAction = renderHeaderAction(input.headerAction)
|
|
91
|
+
const mobileNav = renderMobileNav(input)
|
|
92
|
+
|
|
93
|
+
const header =
|
|
94
|
+
`<header class="cg-site-header"><div class="cg-site-header__inner">` +
|
|
95
|
+
`<a class="cg-site-header__home" href="${escapeAttribute(input.homeHref)}">${mark}</a>` +
|
|
96
|
+
`${
|
|
97
|
+
headerNav === ''
|
|
98
|
+
? ''
|
|
99
|
+
: `<nav class="cg-nav" aria-label="Primary"><ul class="cg-nav__items">${headerNav}</ul></nav>`
|
|
100
|
+
}` +
|
|
101
|
+
`${themeToggle}` +
|
|
102
|
+
`${headerAction}` +
|
|
103
|
+
`${mobileNav}` +
|
|
104
|
+
`</div></header>`
|
|
105
|
+
|
|
106
|
+
const tagline =
|
|
107
|
+
input.tagline === undefined
|
|
108
|
+
? ''
|
|
109
|
+
: `<p class="cg-site-footer__tagline">${escapeText(input.tagline)}</p>`
|
|
110
|
+
const social =
|
|
111
|
+
input.social === undefined
|
|
112
|
+
? ''
|
|
113
|
+
: serialize(
|
|
114
|
+
renderSocialLinks(input.social, {
|
|
115
|
+
className: 'cg-site-footer__social',
|
|
116
|
+
itemClassName: 'cg-site-footer__social-item',
|
|
117
|
+
}) ?? { kind: 'text', value: '' },
|
|
118
|
+
)
|
|
119
|
+
const footerNote =
|
|
120
|
+
input.footerNote === undefined
|
|
121
|
+
? ''
|
|
122
|
+
: `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
|
|
123
|
+
|
|
124
|
+
const footer =
|
|
125
|
+
`<footer class="cg-site-footer"><div class="cg-site-footer__grid">` +
|
|
126
|
+
`<div class="cg-site-footer__brand">` +
|
|
127
|
+
`<a class="cg-site-footer__brand-name" href="${escapeAttribute(input.homeHref)}">${siteNameText}</a>` +
|
|
128
|
+
`${tagline}${social}</div>` +
|
|
129
|
+
`${
|
|
130
|
+
footerNav === ''
|
|
131
|
+
? ''
|
|
132
|
+
: `<nav class="cg-site-footer__nav" aria-label="Footer"><ul class="cg-nav__items">${footerNav}</ul></nav>`
|
|
133
|
+
}` +
|
|
134
|
+
`<div class="cg-site-footer__about">${footerNote}</div>` +
|
|
135
|
+
`</div><div class="cg-site-footer__bottom">` +
|
|
136
|
+
`<span>${siteNameText}</span>` +
|
|
137
|
+
`<div class="cg-site-footer__branding">${input.brandingHtml}</div>` +
|
|
138
|
+
`</div></footer>`
|
|
139
|
+
|
|
140
|
+
return { header, footer }
|
|
141
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { type BlockRegistry, VOCABULARY_NAMES, type VocabularyBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
type FetchedEntries,
|
|
4
|
+
type HtmlElement,
|
|
5
|
+
h,
|
|
6
|
+
type PageContent,
|
|
7
|
+
pageHasOwnHeading,
|
|
8
|
+
type RenderContext,
|
|
9
|
+
renderEntryHeader,
|
|
10
|
+
resolveBlockForRender,
|
|
11
|
+
withBlockKey,
|
|
12
|
+
withBlockVariant,
|
|
13
|
+
} from '@cogenta/theme-kit'
|
|
14
|
+
import { renderAccordion } from './blocks/accordion.js'
|
|
15
|
+
import { renderCollectionList } from './blocks/collection-list.js'
|
|
16
|
+
import { renderCta } from './blocks/cta.js'
|
|
17
|
+
import { renderEmbed } from './blocks/embed.js'
|
|
18
|
+
import { renderFaq } from './blocks/faq.js'
|
|
19
|
+
import { renderFeatureGrid } from './blocks/feature-grid.js'
|
|
20
|
+
import { renderGallery } from './blocks/gallery.js'
|
|
21
|
+
import { renderHero } from './blocks/hero.js'
|
|
22
|
+
import { renderLogoStrip } from './blocks/logo-strip.js'
|
|
23
|
+
import { renderLogos } from './blocks/logos.js'
|
|
24
|
+
import { renderMediaFigure } from './blocks/media-figure.js'
|
|
25
|
+
import { renderPricingTable } from './blocks/pricing-table.js'
|
|
26
|
+
import { renderProse } from './blocks/prose.js'
|
|
27
|
+
import { renderQuote } from './blocks/quote.js'
|
|
28
|
+
import { renderStatCounter } from './blocks/stat-counter.js'
|
|
29
|
+
import { renderStats } from './blocks/stats.js'
|
|
30
|
+
import { renderTestimonial } from './blocks/testimonial.js'
|
|
31
|
+
|
|
32
|
+
export type { FetchedEntries, PageContent }
|
|
33
|
+
|
|
34
|
+
export function renderBlock(
|
|
35
|
+
block: VocabularyBlock,
|
|
36
|
+
ctx: RenderContext,
|
|
37
|
+
entries: FetchedEntries = {},
|
|
38
|
+
registry?: BlockRegistry,
|
|
39
|
+
): HtmlElement | null {
|
|
40
|
+
// `block`'s type says `VocabularyBlock`, but the value crossing this
|
|
41
|
+
// boundary from stored content is not always literally one of the shared
|
|
42
|
+
// vocabulary — resolving here is what turns an unimplemented theme-private
|
|
43
|
+
// block into its declared fallback instead of a silently blank slot.
|
|
44
|
+
const resolved = resolveBlockForRender(block, VOCABULARY_NAMES, registry)
|
|
45
|
+
if (resolved === null) return null
|
|
46
|
+
const known = resolved as unknown as VocabularyBlock
|
|
47
|
+
return withBlockVariant(renderKnownBlock(known, ctx, entries), known.variant)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderKnownBlock(
|
|
51
|
+
known: VocabularyBlock,
|
|
52
|
+
ctx: RenderContext,
|
|
53
|
+
entries: FetchedEntries,
|
|
54
|
+
): HtmlElement | null {
|
|
55
|
+
switch (known._type) {
|
|
56
|
+
case 'hero':
|
|
57
|
+
return renderHero(known, ctx)
|
|
58
|
+
case 'prose':
|
|
59
|
+
return renderProse(known, ctx)
|
|
60
|
+
case 'mediaFigure':
|
|
61
|
+
return renderMediaFigure(known, ctx)
|
|
62
|
+
case 'featureGrid':
|
|
63
|
+
return renderFeatureGrid(known, ctx)
|
|
64
|
+
case 'cta':
|
|
65
|
+
return renderCta(known, ctx)
|
|
66
|
+
case 'gallery':
|
|
67
|
+
return renderGallery(known, ctx)
|
|
68
|
+
case 'quote':
|
|
69
|
+
return renderQuote(known, ctx)
|
|
70
|
+
case 'faq':
|
|
71
|
+
return renderFaq(known, ctx)
|
|
72
|
+
case 'stats':
|
|
73
|
+
return renderStats(known, ctx)
|
|
74
|
+
case 'logos':
|
|
75
|
+
return renderLogos(known, ctx)
|
|
76
|
+
case 'collectionList':
|
|
77
|
+
return renderCollectionList(known, ctx, entries[known._key] ?? [])
|
|
78
|
+
case 'embed':
|
|
79
|
+
return renderEmbed(known, ctx)
|
|
80
|
+
case 'testimonial':
|
|
81
|
+
return renderTestimonial(known, ctx)
|
|
82
|
+
case 'pricingTable':
|
|
83
|
+
return renderPricingTable(known, ctx)
|
|
84
|
+
case 'accordion':
|
|
85
|
+
return renderAccordion(known, ctx)
|
|
86
|
+
case 'statCounter':
|
|
87
|
+
return renderStatCounter(known, ctx)
|
|
88
|
+
case 'logoStrip':
|
|
89
|
+
return renderLogoStrip(known, ctx)
|
|
90
|
+
default: {
|
|
91
|
+
// Exhaustive over contract B's vocabulary: `known` is `never` here, so
|
|
92
|
+
// a block this package does not implement stops it compiling until it
|
|
93
|
+
// is.
|
|
94
|
+
const unreachable: never = known
|
|
95
|
+
void unreachable
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `<main id="cg-main">` is mandatory: it is the skip-link's target, written
|
|
103
|
+
* once by `@cogenta/cli`'s `theme-render.ts` outside any theme's control.
|
|
104
|
+
*
|
|
105
|
+
* `withBlockKey` stamps every rendered block with its contract-B `_key` —
|
|
106
|
+
* required so the visual page builder (L16) can map a clicked element in
|
|
107
|
+
* the rendered iframe back to the block that produced it.
|
|
108
|
+
*
|
|
109
|
+
* A routed collection with no `blocks` field of its own — this theme's
|
|
110
|
+
* `menu_item`, in particular — reaches here with an empty block list, so
|
|
111
|
+
* `page.entry` (contract D `theme@1.4`) is what carries its furniture:
|
|
112
|
+
* `renderEntryHeader` draws the title, excerpt (the dish's own
|
|
113
|
+
* `description`, via `entryExcerpt`'s field-name convention) and cover
|
|
114
|
+
* photo. `PageEntryMeta` has no room for a schema-specific field such as
|
|
115
|
+
* `price`/`category`, so a menu item's own page cannot show them without a
|
|
116
|
+
* contract D change — the theme's real, honest limit; the price and
|
|
117
|
+
* category chip the brief also asks for are exactly what the home page's
|
|
118
|
+
* `collectionList` menu already renders for every dish, and the item page
|
|
119
|
+
* still links back there.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* A second, theme-local stamp beside `withBlockKey`'s own `data-block-key`:
|
|
123
|
+
* a real `id` attribute, so this theme's single-page navigation (the
|
|
124
|
+
* blueprint's header/footer links into `#home-menu`/`#home-story`/etc.) has
|
|
125
|
+
* something to scroll to. Contract B's `_key` is already guaranteed unique
|
|
126
|
+
* within one page's block list (the reorder/diff identity every block
|
|
127
|
+
* carries), so reusing it as the DOM id can never collide on a page this
|
|
128
|
+
* theme rendered — safe to do generically, for every block, not only the
|
|
129
|
+
* ones a blueprint happens to link to.
|
|
130
|
+
*/
|
|
131
|
+
function withAnchorId(element: HtmlElement | null, key: string): HtmlElement | null {
|
|
132
|
+
if (element === null) return null
|
|
133
|
+
return { ...element, attrs: { ...element.attrs, id: key } }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function renderPage(
|
|
137
|
+
page: PageContent,
|
|
138
|
+
ctx: RenderContext,
|
|
139
|
+
entries: FetchedEntries = {},
|
|
140
|
+
registry?: BlockRegistry,
|
|
141
|
+
): HtmlElement {
|
|
142
|
+
const heading = pageHasOwnHeading(page.blocks)
|
|
143
|
+
? null
|
|
144
|
+
: (renderEntryHeader(page, ctx) ?? h('h1', { class: 'cg-page__title' }, page.title))
|
|
145
|
+
return h(
|
|
146
|
+
'main',
|
|
147
|
+
{ class: 'cg-main', id: 'cg-main' },
|
|
148
|
+
heading,
|
|
149
|
+
page.blocks.map((block) =>
|
|
150
|
+
withAnchorId(
|
|
151
|
+
withBlockKey(renderBlock(block, ctx, entries, registry), block._key),
|
|
152
|
+
block._key,
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type HtmlElement, h, type TermArchiveInput } from '@cogenta/theme-kit'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The taxonomy-term archive (contract D `theme@1.3`) — a resource index.
|
|
5
|
+
*
|
|
6
|
+
* Built from the same `cg-index__row` rows this theme's `collectionList`
|
|
7
|
+
* dishes would sit in, minus the price column: a date rail on the left,
|
|
8
|
+
* the title and its summary on the right.
|
|
9
|
+
*/
|
|
10
|
+
export function renderTermArchive(input: TermArchiveInput): HtmlElement {
|
|
11
|
+
const rows = input.entries.map((entry) =>
|
|
12
|
+
h(
|
|
13
|
+
'li',
|
|
14
|
+
{ class: 'cg-index__row' },
|
|
15
|
+
entry.publishedAt === null
|
|
16
|
+
? null
|
|
17
|
+
: h(
|
|
18
|
+
'time',
|
|
19
|
+
{ class: 'cg-index__date', datetime: entry.publishedAt },
|
|
20
|
+
entry.publishedAt.slice(0, 10),
|
|
21
|
+
),
|
|
22
|
+
h(
|
|
23
|
+
'div',
|
|
24
|
+
{ class: 'cg-index__body' },
|
|
25
|
+
h(
|
|
26
|
+
'h2',
|
|
27
|
+
{ class: 'cg-index__title' },
|
|
28
|
+
entry.href === null
|
|
29
|
+
? entry.title
|
|
30
|
+
: h('a', { class: 'cg-index__link', href: entry.href }, entry.title),
|
|
31
|
+
),
|
|
32
|
+
entry.summary === null ? null : h('p', { class: 'cg-index__excerpt' }, entry.summary),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return h(
|
|
38
|
+
'main',
|
|
39
|
+
{ class: 'cg-main cg-archive', id: 'cg-main' },
|
|
40
|
+
input.ancestors.length === 0
|
|
41
|
+
? null
|
|
42
|
+
: h(
|
|
43
|
+
'nav',
|
|
44
|
+
{ class: 'cg-archive__breadcrumb', 'aria-label': input.labels.breadcrumb },
|
|
45
|
+
h(
|
|
46
|
+
'ol',
|
|
47
|
+
{},
|
|
48
|
+
...input.ancestors.map((l) => h('li', {}, h('a', { href: l.href }, l.label))),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
h('h1', { class: 'cg-index__title-heading' }, input.term.label),
|
|
52
|
+
input.children.length === 0
|
|
53
|
+
? null
|
|
54
|
+
: h(
|
|
55
|
+
'ul',
|
|
56
|
+
{ class: 'cg-archive__children', 'aria-label': input.labels.subterms },
|
|
57
|
+
...input.children.map((c) => h('li', {}, h('a', { href: c.href }, c.label))),
|
|
58
|
+
),
|
|
59
|
+
rows.length === 0
|
|
60
|
+
? h('p', { class: 'cg-index__empty' }, input.labels.empty)
|
|
61
|
+
: h('ul', { class: 'cg-index__items' }, ...rows),
|
|
62
|
+
pager(input),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pager(input: TermArchiveInput): HtmlElement | null {
|
|
67
|
+
if (input.page.previousHref === null && input.page.nextHref === null) return null
|
|
68
|
+
return h(
|
|
69
|
+
'nav',
|
|
70
|
+
{ class: 'cg-archive__pager', 'aria-label': input.labels.pagination },
|
|
71
|
+
input.page.previousHref === null
|
|
72
|
+
? null
|
|
73
|
+
: h('a', { rel: 'prev', href: input.page.previousHref }, input.labels.previous),
|
|
74
|
+
input.page.nextHref === null
|
|
75
|
+
? null
|
|
76
|
+
: h('a', { rel: 'next', href: input.page.nextHref }, input.labels.next),
|
|
77
|
+
)
|
|
78
|
+
}
|