@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.
Files changed (117) hide show
  1. package/LICENSE +373 -0
  2. package/dist/src/index.d.ts +22 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/index.js +25 -0
  5. package/dist/src/index.js.map +1 -0
  6. package/dist/src/render/blocks/accordion.d.ts +4 -0
  7. package/dist/src/render/blocks/accordion.d.ts.map +1 -0
  8. package/dist/src/render/blocks/accordion.js +21 -0
  9. package/dist/src/render/blocks/accordion.js.map +1 -0
  10. package/dist/src/render/blocks/collection-list.d.ts +13 -0
  11. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  12. package/dist/src/render/blocks/collection-list.js +109 -0
  13. package/dist/src/render/blocks/collection-list.js.map +1 -0
  14. package/dist/src/render/blocks/cta.d.ts +10 -0
  15. package/dist/src/render/blocks/cta.d.ts.map +1 -0
  16. package/dist/src/render/blocks/cta.js +13 -0
  17. package/dist/src/render/blocks/cta.js.map +1 -0
  18. package/dist/src/render/blocks/embed.d.ts +4 -0
  19. package/dist/src/render/blocks/embed.d.ts.map +1 -0
  20. package/dist/src/render/blocks/embed.js +82 -0
  21. package/dist/src/render/blocks/embed.js.map +1 -0
  22. package/dist/src/render/blocks/faq.d.ts +4 -0
  23. package/dist/src/render/blocks/faq.d.ts.map +1 -0
  24. package/dist/src/render/blocks/faq.js +17 -0
  25. package/dist/src/render/blocks/faq.js.map +1 -0
  26. package/dist/src/render/blocks/feature-grid.d.ts +4 -0
  27. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
  28. package/dist/src/render/blocks/feature-grid.js +24 -0
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -0
  30. package/dist/src/render/blocks/gallery.d.ts +11 -0
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  32. package/dist/src/render/blocks/gallery.js +20 -0
  33. package/dist/src/render/blocks/gallery.js.map +1 -0
  34. package/dist/src/render/blocks/hero.d.ts +16 -0
  35. package/dist/src/render/blocks/hero.d.ts.map +1 -0
  36. package/dist/src/render/blocks/hero.js +32 -0
  37. package/dist/src/render/blocks/hero.js.map +1 -0
  38. package/dist/src/render/blocks/logo-strip.d.ts +4 -0
  39. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
  40. package/dist/src/render/blocks/logo-strip.js +16 -0
  41. package/dist/src/render/blocks/logo-strip.js.map +1 -0
  42. package/dist/src/render/blocks/logos.d.ts +4 -0
  43. package/dist/src/render/blocks/logos.d.ts.map +1 -0
  44. package/dist/src/render/blocks/logos.js +23 -0
  45. package/dist/src/render/blocks/logos.js.map +1 -0
  46. package/dist/src/render/blocks/media-figure.d.ts +9 -0
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  48. package/dist/src/render/blocks/media-figure.js +24 -0
  49. package/dist/src/render/blocks/media-figure.js.map +1 -0
  50. package/dist/src/render/blocks/pricing-table.d.ts +4 -0
  51. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
  52. package/dist/src/render/blocks/pricing-table.js +30 -0
  53. package/dist/src/render/blocks/pricing-table.js.map +1 -0
  54. package/dist/src/render/blocks/prose.d.ts +13 -0
  55. package/dist/src/render/blocks/prose.d.ts.map +1 -0
  56. package/dist/src/render/blocks/prose.js +14 -0
  57. package/dist/src/render/blocks/prose.js.map +1 -0
  58. package/dist/src/render/blocks/quote.d.ts +8 -0
  59. package/dist/src/render/blocks/quote.d.ts.map +1 -0
  60. package/dist/src/render/blocks/quote.js +21 -0
  61. package/dist/src/render/blocks/quote.js.map +1 -0
  62. package/dist/src/render/blocks/stat-counter.d.ts +4 -0
  63. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
  64. package/dist/src/render/blocks/stat-counter.js +15 -0
  65. package/dist/src/render/blocks/stat-counter.js.map +1 -0
  66. package/dist/src/render/blocks/stats.d.ts +4 -0
  67. package/dist/src/render/blocks/stats.d.ts.map +1 -0
  68. package/dist/src/render/blocks/stats.js +21 -0
  69. package/dist/src/render/blocks/stats.js.map +1 -0
  70. package/dist/src/render/blocks/testimonial.d.ts +17 -0
  71. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  72. package/dist/src/render/blocks/testimonial.js +26 -0
  73. package/dist/src/render/blocks/testimonial.js.map +1 -0
  74. package/dist/src/render/chrome.d.ts +3 -0
  75. package/dist/src/render/chrome.d.ts.map +1 -0
  76. package/dist/src/render/chrome.js +108 -0
  77. package/dist/src/render/chrome.js.map +1 -0
  78. package/dist/src/render/render-block.d.ts +6 -0
  79. package/dist/src/render/render-block.d.ts.map +1 -0
  80. package/dist/src/render/render-block.js +118 -0
  81. package/dist/src/render/render-block.js.map +1 -0
  82. package/dist/src/render/term-archive.d.ts +10 -0
  83. package/dist/src/render/term-archive.d.ts.map +1 -0
  84. package/dist/src/render/term-archive.js +32 -0
  85. package/dist/src/render/term-archive.js.map +1 -0
  86. package/dist/theme.config.d.ts +11 -0
  87. package/dist/theme.config.d.ts.map +1 -0
  88. package/dist/theme.config.js +41 -0
  89. package/dist/theme.config.js.map +1 -0
  90. package/package.json +57 -0
  91. package/src/index.ts +24 -0
  92. package/src/render/blocks/accordion.ts +57 -0
  93. package/src/render/blocks/collection-list.ts +189 -0
  94. package/src/render/blocks/cta.ts +31 -0
  95. package/src/render/blocks/embed.ts +105 -0
  96. package/src/render/blocks/faq.ts +53 -0
  97. package/src/render/blocks/feature-grid.ts +61 -0
  98. package/src/render/blocks/gallery.ts +40 -0
  99. package/src/render/blocks/hero.ts +58 -0
  100. package/src/render/blocks/logo-strip.ts +35 -0
  101. package/src/render/blocks/logos.ts +53 -0
  102. package/src/render/blocks/media-figure.ts +39 -0
  103. package/src/render/blocks/pricing-table.ts +74 -0
  104. package/src/render/blocks/prose.ts +19 -0
  105. package/src/render/blocks/quote.ts +38 -0
  106. package/src/render/blocks/stat-counter.ts +37 -0
  107. package/src/render/blocks/stats.ts +48 -0
  108. package/src/render/blocks/testimonial.ts +42 -0
  109. package/src/render/chrome.ts +141 -0
  110. package/src/render/render-block.ts +156 -0
  111. package/src/render/term-archive.ts +78 -0
  112. package/src/styles/archive.css +84 -0
  113. package/src/styles/base.css +923 -0
  114. package/src/styles/blocks.css +975 -0
  115. package/src/styles/theme.css +36 -0
  116. package/src/styles/tokens.css +320 -0
  117. 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
+ }