@cogenta/theme-restaurant 0.3.2 → 0.5.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.
Files changed (138) hide show
  1. package/dist/src/index.d.ts +3 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +3 -0
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/render/blocks/accordion.d.ts +7 -0
  6. package/dist/src/render/blocks/accordion.d.ts.map +1 -1
  7. package/dist/src/render/blocks/accordion.js +10 -16
  8. package/dist/src/render/blocks/accordion.js.map +1 -1
  9. package/dist/src/render/blocks/collection-list.d.ts +4 -11
  10. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  11. package/dist/src/render/blocks/collection-list.js +96 -96
  12. package/dist/src/render/blocks/collection-list.js.map +1 -1
  13. package/dist/src/render/blocks/cta.d.ts +7 -4
  14. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  15. package/dist/src/render/blocks/cta.js +9 -7
  16. package/dist/src/render/blocks/cta.js.map +1 -1
  17. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  18. package/dist/src/render/blocks/embed.js +30 -28
  19. package/dist/src/render/blocks/embed.js.map +1 -1
  20. package/dist/src/render/blocks/faq.d.ts +13 -1
  21. package/dist/src/render/blocks/faq.d.ts.map +1 -1
  22. package/dist/src/render/blocks/faq.js +15 -11
  23. package/dist/src/render/blocks/faq.js.map +1 -1
  24. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  25. package/dist/src/render/blocks/feature-grid.js +43 -16
  26. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  27. package/dist/src/render/blocks/gallery.d.ts +0 -7
  28. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  29. package/dist/src/render/blocks/gallery.js +28 -10
  30. package/dist/src/render/blocks/gallery.js.map +1 -1
  31. package/dist/src/render/blocks/hero.d.ts +16 -9
  32. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  33. package/dist/src/render/blocks/hero.js +27 -20
  34. package/dist/src/render/blocks/hero.js.map +1 -1
  35. package/dist/src/render/blocks/logo-strip.d.ts +6 -0
  36. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
  37. package/dist/src/render/blocks/logo-strip.js +7 -9
  38. package/dist/src/render/blocks/logo-strip.js.map +1 -1
  39. package/dist/src/render/blocks/logos.d.ts +8 -0
  40. package/dist/src/render/blocks/logos.d.ts.map +1 -1
  41. package/dist/src/render/blocks/logos.js +18 -18
  42. package/dist/src/render/blocks/logos.js.map +1 -1
  43. package/dist/src/render/blocks/media-figure.d.ts +14 -3
  44. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  45. package/dist/src/render/blocks/media-figure.js +34 -16
  46. package/dist/src/render/blocks/media-figure.js.map +1 -1
  47. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  48. package/dist/src/render/blocks/pricing-table.js +17 -22
  49. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  50. package/dist/src/render/blocks/prose.d.ts +11 -6
  51. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  52. package/dist/src/render/blocks/prose.js +13 -7
  53. package/dist/src/render/blocks/prose.js.map +1 -1
  54. package/dist/src/render/blocks/quote.d.ts +6 -2
  55. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  56. package/dist/src/render/blocks/quote.js +14 -12
  57. package/dist/src/render/blocks/quote.js.map +1 -1
  58. package/dist/src/render/blocks/stat-counter.d.ts +6 -0
  59. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
  60. package/dist/src/render/blocks/stat-counter.js +7 -10
  61. package/dist/src/render/blocks/stat-counter.js.map +1 -1
  62. package/dist/src/render/blocks/stats.d.ts +7 -0
  63. package/dist/src/render/blocks/stats.d.ts.map +1 -1
  64. package/dist/src/render/blocks/stats.js +8 -16
  65. package/dist/src/render/blocks/stats.js.map +1 -1
  66. package/dist/src/render/blocks/testimonial.d.ts +5 -11
  67. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  68. package/dist/src/render/blocks/testimonial.js +9 -17
  69. package/dist/src/render/blocks/testimonial.js.map +1 -1
  70. package/dist/src/render/chrome.d.ts.map +1 -1
  71. package/dist/src/render/chrome.js +101 -84
  72. package/dist/src/render/chrome.js.map +1 -1
  73. package/dist/src/render/dish.d.ts +13 -0
  74. package/dist/src/render/dish.d.ts.map +1 -0
  75. package/dist/src/render/dish.js +84 -0
  76. package/dist/src/render/dish.js.map +1 -0
  77. package/dist/src/render/layout.d.ts +23 -0
  78. package/dist/src/render/layout.d.ts.map +1 -0
  79. package/dist/src/render/layout.js +33 -0
  80. package/dist/src/render/layout.js.map +1 -0
  81. package/dist/src/render/menu.d.ts +53 -0
  82. package/dist/src/render/menu.d.ts.map +1 -0
  83. package/dist/src/render/menu.js +99 -0
  84. package/dist/src/render/menu.js.map +1 -0
  85. package/dist/src/render/render-block.d.ts +19 -0
  86. package/dist/src/render/render-block.d.ts.map +1 -1
  87. package/dist/src/render/render-block.js +50 -39
  88. package/dist/src/render/render-block.js.map +1 -1
  89. package/dist/src/render/strings.d.ts +15 -0
  90. package/dist/src/render/strings.d.ts.map +1 -0
  91. package/dist/src/render/strings.js +23 -0
  92. package/dist/src/render/strings.js.map +1 -0
  93. package/dist/src/render/term-archive.d.ts +10 -4
  94. package/dist/src/render/term-archive.d.ts.map +1 -1
  95. package/dist/src/render/term-archive.js +25 -16
  96. package/dist/src/render/term-archive.js.map +1 -1
  97. package/dist/src/render/widget-areas.d.ts +13 -0
  98. package/dist/src/render/widget-areas.d.ts.map +1 -0
  99. package/dist/src/render/widget-areas.js +20 -0
  100. package/dist/src/render/widget-areas.js.map +1 -0
  101. package/dist/theme.config.d.ts +1 -1
  102. package/dist/theme.config.js +2 -2
  103. package/dist/theme.config.js.map +1 -1
  104. package/package.json +5 -5
  105. package/src/index.ts +3 -0
  106. package/src/render/blocks/accordion.ts +18 -47
  107. package/src/render/blocks/collection-list.ts +245 -133
  108. package/src/render/blocks/cta.ts +20 -11
  109. package/src/render/blocks/embed.ts +45 -36
  110. package/src/render/blocks/faq.ts +32 -25
  111. package/src/render/blocks/feature-grid.ts +83 -33
  112. package/src/render/blocks/gallery.ts +39 -21
  113. package/src/render/blocks/hero.ts +41 -34
  114. package/src/render/blocks/logo-strip.ts +27 -25
  115. package/src/render/blocks/logos.ts +34 -43
  116. package/src/render/blocks/media-figure.ts +49 -24
  117. package/src/render/blocks/pricing-table.ts +30 -42
  118. package/src/render/blocks/prose.ts +18 -9
  119. package/src/render/blocks/quote.ts +29 -19
  120. package/src/render/blocks/stat-counter.ts +24 -29
  121. package/src/render/blocks/stats.ts +30 -40
  122. package/src/render/blocks/testimonial.ts +17 -22
  123. package/src/render/chrome.ts +113 -95
  124. package/src/render/dish.ts +195 -0
  125. package/src/render/layout.ts +64 -0
  126. package/src/render/menu.ts +117 -0
  127. package/src/render/render-block.ts +51 -40
  128. package/src/render/strings.ts +44 -0
  129. package/src/render/term-archive.ts +78 -44
  130. package/src/render/widget-areas.ts +23 -0
  131. package/src/styles/archive.css +46 -56
  132. package/src/styles/base.css +807 -554
  133. package/src/styles/blocks.css +723 -641
  134. package/src/styles/menu.css +482 -0
  135. package/src/styles/theme.css +24 -25
  136. package/src/styles/tokens.css +239 -225
  137. package/src/styles/widgets.css +853 -0
  138. package/tokens.json +16 -16
@@ -5,136 +5,154 @@ 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
- * 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.
15
+ * The restaurant's header and footer (`theme@1.4`).
19
16
  *
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`.
17
+ * Header: one quiet row on the grid, under a hairline. The restaurant's name
18
+ * on the left in the display serif (or its uploaded logo), the pages in words
19
+ * at the text size, then the `headerAction` ("Reserve") as the one underlined
20
+ * link after a hairline, and the light/dark control. The action sits outside
21
+ * the navigation list on purpose: on a phone it stays in the header row
22
+ * beside the menu toggle, because booking a table is what most visitors came
23
+ * to do.
26
24
  *
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.
25
+ * Below the wide breakpoint the pages become a panel of large serif links
26
+ * under the header, opened by a CSS-only toggle: a visually hidden checkbox
27
+ * paired with a `<label>`, placed before the `<nav>` so the sibling
28
+ * combinator reaches it. One `<nav>` in the markup, retiled by CSS. Under
29
+ * the links the open panel repeats the `footerNote` (the address card), which
30
+ * the stylesheet shows in the panel only.
31
+ *
32
+ * Footer: a charcoal band. The name and the `tagline`; the `footerNote` set as
33
+ * the restaurant's address card (a blank line starts a new paragraph, a line
34
+ * break stays a line break, so an address and a set of opening hours keep
35
+ * their shape); the footer navigation; the social profiles with their names;
36
+ * the footer widget columns (`theme@1.6`), when a site has placed any, as a
37
+ * second tier under a hairline; then the legal line, with the copyright year and the name beside the host's
38
+ * `brandingHtml`, placed once, as received. Every `theme@1.4` field renders
39
+ * only when present.
32
40
  */
33
41
 
34
- function navItems(links: readonly ChromeNavLink[]): string {
42
+ function navItems(links: readonly ChromeNavLink[], className: string): string {
35
43
  return links
36
44
  .filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
37
45
  .map((link) => {
38
46
  const label = escapeText(link.label)
39
47
  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)
48
+ if (link.href === null) {
49
+ return `<li class="${className}"><span${titleAttr}>${label}</span></li>`
50
+ }
42
51
  const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
43
- return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
52
+ return `<li class="${className}"><a href="${escapeAttribute(link.href)}"${target}${titleAttr}>${label}</a></li>`
44
53
  })
45
54
  .join('')
46
55
  }
47
56
 
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
- )
57
+ /** `footerNote` as paragraphs at blank lines, each keeping its own line breaks. */
58
+ function renderFooterNote(note: string, className: string): string {
59
+ const paragraphs = note
60
+ .replace(/\r\n?/g, '\n')
61
+ .split(/\n\s*\n/)
62
+ .map((paragraph) =>
63
+ paragraph
64
+ .split('\n')
65
+ .map((line) => line.trim())
66
+ .filter((line) => line !== ''),
67
+ )
68
+ .filter((lines) => lines.length > 0)
69
+ if (paragraphs.length === 0) return ''
70
+ return `<div class="${className}">${paragraphs
71
+ .map((lines) => `<p>${lines.map((line) => escapeText(line)).join('<br>')}</p>`)
72
+ .join('')}</div>`
79
73
  }
80
74
 
81
75
  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)
76
+ const siteName = escapeText(input.site.name)
77
+ const home = escapeAttribute(input.homeHref)
78
+ const links = navItems(input.headerNav, 'cr-header__item')
79
+ const action =
80
+ input.headerAction === undefined
81
+ ? ''
82
+ : `<a class="cr-header__action" href="${escapeAttribute(input.headerAction.href)}">${escapeText(
83
+ input.headerAction.label,
84
+ )}</a>`
85
+ const toggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
86
+ const logo = renderBrandMark(input.brand, { className: 'cr-header__logo' })
87
+ const mark = logo ?? `<span class="cr-header__wordmark">${siteName}</span>`
88
+
89
+ // On a phone the open panel also carries the address card, because the
90
+ // address and the hours are what a guest on the street opens the menu for.
91
+ // It is the footer's own note, shown only in the panel.
92
+ const panelCard =
93
+ input.footerNote === undefined ? '' : renderFooterNote(input.footerNote, 'cr-header__card')
94
+
95
+ const menu =
96
+ links === ''
97
+ ? ''
98
+ : `<input type="checkbox" id="cr-nav-toggle" class="cr-nav-toggle-input" aria-label="Navigation">` +
99
+ `<label for="cr-nav-toggle" class="cr-nav-toggle-label" aria-hidden="true">` +
100
+ `<span class="cr-nav-toggle-bar"></span><span class="cr-nav-toggle-bar"></span>` +
101
+ `</label>` +
102
+ `<nav class="cr-header__nav" id="cr-nav" aria-label="Primary">` +
103
+ `<ul class="cr-header__links">${links}</ul>` +
104
+ `${panelCard}` +
105
+ `</nav>`
92
106
 
93
107
  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}` +
108
+ `<header class="cr-header" data-nav="${links === '' ? 'none' : 'links'}" data-action="${
109
+ action === '' ? 'none' : 'link'
110
+ }">` +
111
+ `<div class="cr-header__inner">` +
112
+ `<a class="cr-header__brand" href="${home}">${mark}</a>` +
113
+ `${menu}` +
114
+ `${action}` +
115
+ `${toggle}` +
104
116
  `</div></header>`
105
117
 
106
118
  const tagline =
107
119
  input.tagline === undefined
108
120
  ? ''
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>`
121
+ : `<p class="cr-footer__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
122
+ const note =
123
+ input.footerNote === undefined ? '' : renderFooterNote(input.footerNote, 'cr-footer__note')
124
+ const footerLinks = navItems(input.footerNav, 'cr-footer__item')
125
+ const social = renderSocialLinks(input.social, {
126
+ className: 'cr-footer__social',
127
+ itemClassName: 'cr-footer__social-item',
128
+ })
129
+ const year = new Date().getFullYear()
130
+ // The footer widget columns (`theme@1.6`): a row of their own under the
131
+ // name, the address card, the pages and the profiles, above the legal
132
+ // line, on the footer's own twelve columns.
133
+ const footerWidgets = renderFooterWidgets(input.widgets, {
134
+ className: 'cr-footer__widgets',
135
+ headingLevel: 'h2',
136
+ })
123
137
 
124
138
  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>` +
139
+ `<footer class="cr-footer"><div class="cr-footer__inner">` +
140
+ `<div class="cr-footer__about">` +
141
+ `<a class="cr-footer__name" href="${home}">${siteName}</a>` +
142
+ `${tagline}` +
143
+ `</div>` +
144
+ `${note}` +
129
145
  `${
130
- footerNav === ''
146
+ footerLinks === ''
131
147
  ? ''
132
- : `<nav class="cg-site-footer__nav" aria-label="Footer"><ul class="cg-nav__items">${footerNav}</ul></nav>`
148
+ : `<nav class="cr-footer__nav" aria-label="Footer"><ul class="cr-footer__links">${footerLinks}</ul></nav>`
133
149
  }` +
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>` +
150
+ `${social === null ? '' : `<div class="cr-footer__follow">${serialize(social)}</div>`}` +
151
+ `${footerWidgets === null ? '' : serialize(footerWidgets)}` +
152
+ `<div class="cr-footer__legal">` +
153
+ `<p class="cr-footer__copyright">© ${year} ${siteName}</p>` +
154
+ `${input.brandingHtml === '' ? '' : `<div class="cr-footer__branding">${input.brandingHtml}</div>`}` +
155
+ `</div>` +
138
156
  `</div></footer>`
139
157
 
140
158
  return { header, footer }
@@ -0,0 +1,195 @@
1
+ import {
2
+ type HtmlElement,
3
+ h,
4
+ type PageContent,
5
+ type PageEntryFieldValue,
6
+ type PageEntryMeta,
7
+ type RenderContext,
8
+ renderImageSource,
9
+ } from '@cogenta/theme-kit'
10
+ import {
11
+ currencyOf,
12
+ DETAIL_FIELDS,
13
+ formatPrice,
14
+ isVegetarian,
15
+ priceOf,
16
+ sectionOf,
17
+ textOf,
18
+ } from './menu.js'
19
+ import { menuString } from './strings.js'
20
+
21
+ /**
22
+ * The page of one dish, and the plain title every other page gets.
23
+ *
24
+ * A dish page (an entry whose `theme@1.5` fields carry a numeric `price`)
25
+ * opens on two columns of the grid, the way a waiter would describe a plate:
26
+ *
27
+ * - the photograph, on the first six columns, cropped to 4:5 like every dish
28
+ * photograph on the site;
29
+ * - on the last five, the part of the menu it belongs to in small capitals,
30
+ * the name in the display serif, the short description in italic, the
31
+ * price in tabular figures on a hairline, and then its details (where it
32
+ * comes from, what to drink with it, the allergens) as a ruled list.
33
+ *
34
+ * A dish without a photograph keeps the same words on the first seven
35
+ * columns, so a glass of wine does not open on an empty frame.
36
+ *
37
+ * There is nothing to order on this page, and nothing pretends there is: a
38
+ * restaurant takes a table, not a dish, and the way to book lives on the
39
+ * site's own reservations page, linked from the header on every page.
40
+ */
41
+
42
+ type Fields = Readonly<Record<string, PageEntryFieldValue>>
43
+
44
+ export function isDishPage(entry: PageEntryMeta | undefined): entry is PageEntryMeta {
45
+ return entry !== undefined && priceOf(entry.fields?.price) !== undefined
46
+ }
47
+
48
+ function details(fields: Fields, locale: string): HtmlElement | null {
49
+ const rows = DETAIL_FIELDS.flatMap((name) => {
50
+ const value = textOf(fields[name])
51
+ if (value === undefined) return []
52
+ return [
53
+ h(
54
+ 'div',
55
+ { class: 'cr-dish__fact', 'data-fact': name },
56
+ h('dt', { class: 'cr-dish__fact-label' }, menuString(locale, name)),
57
+ h('dd', { class: 'cr-dish__fact-value' }, value),
58
+ ),
59
+ ]
60
+ })
61
+ if (rows.length === 0) return null
62
+ return h(
63
+ 'div',
64
+ { class: 'cr-dish__details' },
65
+ h('h2', { class: 'cg-visually-hidden' }, menuString(locale, 'details')),
66
+ h('dl', { class: 'cr-dish__facts' }, rows),
67
+ )
68
+ }
69
+
70
+ function terms(entry: PageEntryMeta): HtmlElement | null {
71
+ if (entry.terms === undefined || entry.terms.length === 0) return null
72
+ return h(
73
+ 'ul',
74
+ { class: 'cr-dish__terms' },
75
+ entry.terms.map((term) =>
76
+ h('li', {}, term.href === null ? term.label : h('a', { href: term.href }, term.label)),
77
+ ),
78
+ )
79
+ }
80
+
81
+ export function renderDishHeader(
82
+ page: PageContent,
83
+ entry: PageEntryMeta,
84
+ ctx: RenderContext,
85
+ ): HtmlElement {
86
+ const fields: Fields = entry.fields ?? {}
87
+ const price = priceOf(fields.price) as number
88
+ const section = sectionOf(fields)
89
+ const image = entry.image
90
+
91
+ return h(
92
+ 'article',
93
+ { class: 'cr-dish', 'data-media': String(image !== undefined) },
94
+ h(
95
+ 'div',
96
+ { class: 'cr-container cr-dish__inner' },
97
+ image === undefined
98
+ ? null
99
+ : h(
100
+ 'figure',
101
+ { class: 'cr-dish__media' },
102
+ renderImageSource(image, {
103
+ className: 'cr-dish__image',
104
+ loading: 'eager',
105
+ sizes: '(min-width: 64rem) 48vw, 100vw',
106
+ }),
107
+ ),
108
+ h(
109
+ 'div',
110
+ { class: 'cr-dish__info' },
111
+ section === undefined ? null : h('p', { class: 'cr-dish__section' }, section),
112
+ h('h1', { class: 'cr-dish__title' }, page.title),
113
+ entry.excerpt === undefined ? null : h('p', { class: 'cr-dish__summary' }, entry.excerpt),
114
+ h(
115
+ 'p',
116
+ { class: 'cr-dish__price-line' },
117
+ h(
118
+ 'span',
119
+ { class: 'cr-dish__price' },
120
+ h('span', { class: 'cg-visually-hidden' }, `${menuString(ctx.locale, 'price')} `),
121
+ h(
122
+ 'data',
123
+ { value: String(price) },
124
+ formatPrice(price, currencyOf(fields.currency), ctx.locale),
125
+ ),
126
+ ),
127
+ isVegetarian(fields)
128
+ ? h('span', { class: 'cr-dish__diet' }, menuString(ctx.locale, 'vegetarian'))
129
+ : null,
130
+ ),
131
+ details(fields, ctx.locale),
132
+ terms(entry),
133
+ ),
134
+ ),
135
+ )
136
+ }
137
+
138
+ function formatDate(iso: string, locale: string): string {
139
+ try {
140
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(new Date(iso))
141
+ } catch {
142
+ return iso
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Date, author and reading time, for a dated entry only: an opening-hours
148
+ * page is not something a guest reads against the clock.
149
+ */
150
+ function pageMeta(entry: PageEntryMeta, ctx: RenderContext): HtmlElement | null {
151
+ if (entry.publishedAt === undefined) return null
152
+ const parts: HtmlElement[] = [
153
+ h('time', { datetime: entry.publishedAt }, formatDate(entry.publishedAt, ctx.locale)),
154
+ ]
155
+ if (entry.author !== undefined) parts.push(h('span', {}, entry.author.name))
156
+ if (entry.readingMinutes !== undefined) {
157
+ parts.push(h('span', {}, ctx.t('entry.readingTime', { minutes: entry.readingMinutes })))
158
+ }
159
+ return h('p', { class: 'cr-page-head__meta' }, parts)
160
+ }
161
+
162
+ /**
163
+ * Any other page: its title large and light on the grid, the entry's summary
164
+ * under it in the serif italic when it has one. A dated entry (a note from
165
+ * the kitchen) also carries its date, author and reading time in one small
166
+ * line, and its cover photograph under the title. An undated entry with a
167
+ * picture keeps the picture for the lists that link to it: its own page
168
+ * opens on words.
169
+ */
170
+ export function renderPageHeader(page: PageContent, ctx: RenderContext): HtmlElement {
171
+ const entry = page.entry
172
+ const cover = entry?.publishedAt === undefined ? undefined : entry.image
173
+ return h(
174
+ 'header',
175
+ { class: 'cr-page-head', 'data-cover': String(cover !== undefined) },
176
+ h(
177
+ 'div',
178
+ { class: 'cr-container cr-page-head__inner' },
179
+ h('h1', { class: 'cr-page-head__title' }, page.title),
180
+ entry?.excerpt === undefined ? null : h('p', { class: 'cr-page-head__lead' }, entry.excerpt),
181
+ entry === undefined ? null : pageMeta(entry, ctx),
182
+ cover === undefined
183
+ ? null
184
+ : h(
185
+ 'figure',
186
+ { class: 'cr-page-head__cover' },
187
+ renderImageSource(cover, {
188
+ className: 'cr-page-head__image',
189
+ loading: 'eager',
190
+ sizes: '(min-width: 90rem) 86rem, 100vw',
191
+ }),
192
+ ),
193
+ ),
194
+ )
195
+ }
@@ -0,0 +1,64 @@
1
+ import {
2
+ type Attributes,
3
+ blockHeadingTag,
4
+ type Child,
5
+ type HtmlElement,
6
+ h,
7
+ heading,
8
+ } from '@cogenta/theme-kit'
9
+
10
+ /**
11
+ * The frame every block of this theme renders into.
12
+ *
13
+ * The outer element carries the one section rhythm (`--cr-section`, the same
14
+ * for every block) and paints edge to edge when a variant asks for a band.
15
+ * The inner element is the twelve-column container, so a section title, a
16
+ * dish name, a caption and a price all start on the same line.
17
+ *
18
+ * `inner` is `figure` for a block whose semantics are a figure (a quotation
19
+ * and its attribution, an image and its caption): `<figcaption>` must be a
20
+ * direct child of its `<figure>`.
21
+ */
22
+ export function section(
23
+ outer: 'section' | 'div',
24
+ block: string,
25
+ className: string,
26
+ attrs: Attributes,
27
+ inner: 'div' | 'figure',
28
+ ...children: readonly Child[]
29
+ ): HtmlElement {
30
+ return h(
31
+ outer,
32
+ { class: `cr-section ${className}`, 'data-block': block, ...attrs },
33
+ h(inner, { class: `cr-container ${className}__inner` }, ...children),
34
+ )
35
+ }
36
+
37
+ /**
38
+ * A titled block's heading: set in the display serif at the section size, on
39
+ * the first column of the grid. A dining room lets the food and the room
40
+ * carry the page, so a section names itself in a few words and nothing else.
41
+ */
42
+ export function sectionHead(blockName: string, title: string | undefined): HtmlElement | null {
43
+ if (title === undefined) return null
44
+ return h(
45
+ 'div',
46
+ { class: 'cr-head' },
47
+ heading(
48
+ blockHeadingTag(blockName) ?? 'h2',
49
+ { class: 'cr-head__title', 'data-field': 'title' },
50
+ title,
51
+ ),
52
+ )
53
+ }
54
+
55
+ /** A paragraph that renders only when the text is there: no empty `<p>` on the page. */
56
+ export function optionalText(
57
+ tag: string,
58
+ className: string,
59
+ value: string | undefined,
60
+ attrs: Attributes = {},
61
+ ): HtmlElement | null {
62
+ if (value === undefined || value.trim() === '') return null
63
+ return h(tag, { class: className, ...attrs }, value)
64
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Reading a dish out of plain content data.
3
+ *
4
+ * Contract A fixes the system fields and nothing else, so a theme can only
5
+ * recognise a dish by convention: an entry with a numeric `price` is
6
+ * something on the menu, whatever its collection is called, and its
7
+ * `category` (or `section`, or `course`) is the part of the menu it belongs
8
+ * to. The same convention reads a dish in a menu list (the raw entry) and on
9
+ * its own page (`theme@1.5`'s `PageEntryMeta.fields`), so the two can never
10
+ * disagree about a price.
11
+ *
12
+ * Money is shown, never computed: no rounding, no service charge, no
13
+ * conversion. The currency comes from the entry's own `currency` field (an
14
+ * ISO 4217 code); contract B has no currency, so an entry that does not say
15
+ * is shown in euros, the currency every dish this theme's blueprint seeds is
16
+ * priced in. A restaurant that charges in another currency sets the field.
17
+ */
18
+
19
+ export const DEFAULT_CURRENCY = 'EUR'
20
+
21
+ /** A finite, non-negative number, or `undefined` for anything else. */
22
+ export function priceOf(value: unknown): number | undefined {
23
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
24
+ }
25
+
26
+ /** An ISO 4217 code the platform accepts, or the default. */
27
+ export function currencyOf(value: unknown): string {
28
+ if (typeof value !== 'string' || !/^[A-Za-z]{3}$/.test(value.trim())) return DEFAULT_CURRENCY
29
+ const code = value.trim().toUpperCase()
30
+ return Intl.supportedValuesOf('currency').includes(code) ? code : DEFAULT_CURRENCY
31
+ }
32
+
33
+ /**
34
+ * `€16` in English, `16 €` in French: the platform's own currency formatting
35
+ * in the page's locale. A whole amount shows no decimals, a price with cents
36
+ * shows two, so a menu of round prices does not read as a column of `.00`.
37
+ */
38
+ export function formatPrice(amount: number, currency: string, locale: string): string {
39
+ const whole = Number.isInteger(amount)
40
+ const options: Intl.NumberFormatOptions = {
41
+ style: 'currency',
42
+ currency,
43
+ minimumFractionDigits: whole ? 0 : 2,
44
+ maximumFractionDigits: whole ? 0 : 2,
45
+ }
46
+ try {
47
+ return new Intl.NumberFormat(locale, options).format(amount)
48
+ } catch {
49
+ return new Intl.NumberFormat('en', options).format(amount)
50
+ }
51
+ }
52
+
53
+ /** A non-empty string, trimmed, or `undefined`. */
54
+ export function textOf(value: unknown): string | undefined {
55
+ if (typeof value !== 'string') return undefined
56
+ const trimmed = value.trim()
57
+ return trimmed === '' ? undefined : trimmed
58
+ }
59
+
60
+ /** The first field among `names` that holds text. */
61
+ export function firstText(
62
+ source: Readonly<Record<string, unknown>>,
63
+ names: readonly string[],
64
+ ): string | undefined {
65
+ for (const name of names) {
66
+ const value = textOf(source[name])
67
+ if (value !== undefined) return value
68
+ }
69
+ return undefined
70
+ }
71
+
72
+ /** The part of the menu a dish belongs to, under whichever of the usual names a collection gave it. */
73
+ export const SECTION_FIELDS = ['category', 'section', 'course'] as const
74
+
75
+ /** The plain details a dish page lists, in the order a guest reads them. */
76
+ export const DETAIL_FIELDS = ['sourcing', 'pairing', 'allergens'] as const
77
+
78
+ export type DetailField = (typeof DETAIL_FIELDS)[number]
79
+
80
+ export function sectionOf(source: Readonly<Record<string, unknown>>): string | undefined {
81
+ return firstText(source, SECTION_FIELDS)
82
+ }
83
+
84
+ /** `vegetarian: true` says so; anything else says nothing. */
85
+ export function isVegetarian(source: Readonly<Record<string, unknown>>): boolean {
86
+ return source.vegetarian === true
87
+ }
88
+
89
+ export interface MenuSection<T> {
90
+ /** `undefined` for dishes that name no section: one untitled group. */
91
+ readonly title: string | undefined
92
+ readonly items: readonly T[]
93
+ }
94
+
95
+ /**
96
+ * Groups dishes by section, keeping the order the list arrived in: a section
97
+ * appears where its first dish does, and its dishes keep their own order
98
+ * inside it. The editor orders the menu by ordering the entries; the theme
99
+ * never sorts it alphabetically behind their back.
100
+ */
101
+ export function groupBySection<T extends Readonly<Record<string, unknown>>>(
102
+ entries: readonly T[],
103
+ ): readonly MenuSection<T>[] {
104
+ const order: (string | undefined)[] = []
105
+ const groups = new Map<string | undefined, T[]>()
106
+ for (const entry of entries) {
107
+ const title = sectionOf(entry)
108
+ const group = groups.get(title)
109
+ if (group === undefined) {
110
+ order.push(title)
111
+ groups.set(title, [entry])
112
+ } else {
113
+ group.push(entry)
114
+ }
115
+ }
116
+ return order.map((title) => ({ title, items: groups.get(title) ?? [] }))
117
+ }