@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.
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/render/blocks/accordion.d.ts +7 -0
- package/dist/src/render/blocks/accordion.d.ts.map +1 -1
- package/dist/src/render/blocks/accordion.js +10 -16
- package/dist/src/render/blocks/accordion.js.map +1 -1
- package/dist/src/render/blocks/collection-list.d.ts +4 -11
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
- package/dist/src/render/blocks/collection-list.js +96 -96
- package/dist/src/render/blocks/collection-list.js.map +1 -1
- package/dist/src/render/blocks/cta.d.ts +7 -4
- package/dist/src/render/blocks/cta.d.ts.map +1 -1
- package/dist/src/render/blocks/cta.js +9 -7
- package/dist/src/render/blocks/cta.js.map +1 -1
- package/dist/src/render/blocks/embed.d.ts.map +1 -1
- package/dist/src/render/blocks/embed.js +30 -28
- package/dist/src/render/blocks/embed.js.map +1 -1
- package/dist/src/render/blocks/faq.d.ts +13 -1
- package/dist/src/render/blocks/faq.d.ts.map +1 -1
- package/dist/src/render/blocks/faq.js +15 -11
- package/dist/src/render/blocks/faq.js.map +1 -1
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
- package/dist/src/render/blocks/feature-grid.js +43 -16
- package/dist/src/render/blocks/feature-grid.js.map +1 -1
- package/dist/src/render/blocks/gallery.d.ts +0 -7
- package/dist/src/render/blocks/gallery.d.ts.map +1 -1
- package/dist/src/render/blocks/gallery.js +28 -10
- package/dist/src/render/blocks/gallery.js.map +1 -1
- package/dist/src/render/blocks/hero.d.ts +16 -9
- package/dist/src/render/blocks/hero.d.ts.map +1 -1
- package/dist/src/render/blocks/hero.js +27 -20
- package/dist/src/render/blocks/hero.js.map +1 -1
- package/dist/src/render/blocks/logo-strip.d.ts +6 -0
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
- package/dist/src/render/blocks/logo-strip.js +7 -9
- package/dist/src/render/blocks/logo-strip.js.map +1 -1
- package/dist/src/render/blocks/logos.d.ts +8 -0
- package/dist/src/render/blocks/logos.d.ts.map +1 -1
- package/dist/src/render/blocks/logos.js +18 -18
- package/dist/src/render/blocks/logos.js.map +1 -1
- package/dist/src/render/blocks/media-figure.d.ts +14 -3
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
- package/dist/src/render/blocks/media-figure.js +34 -16
- package/dist/src/render/blocks/media-figure.js.map +1 -1
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
- package/dist/src/render/blocks/pricing-table.js +17 -22
- package/dist/src/render/blocks/pricing-table.js.map +1 -1
- package/dist/src/render/blocks/prose.d.ts +11 -6
- package/dist/src/render/blocks/prose.d.ts.map +1 -1
- package/dist/src/render/blocks/prose.js +13 -7
- package/dist/src/render/blocks/prose.js.map +1 -1
- package/dist/src/render/blocks/quote.d.ts +6 -2
- package/dist/src/render/blocks/quote.d.ts.map +1 -1
- package/dist/src/render/blocks/quote.js +14 -12
- package/dist/src/render/blocks/quote.js.map +1 -1
- package/dist/src/render/blocks/stat-counter.d.ts +6 -0
- package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
- package/dist/src/render/blocks/stat-counter.js +7 -10
- package/dist/src/render/blocks/stat-counter.js.map +1 -1
- package/dist/src/render/blocks/stats.d.ts +7 -0
- package/dist/src/render/blocks/stats.d.ts.map +1 -1
- package/dist/src/render/blocks/stats.js +8 -16
- package/dist/src/render/blocks/stats.js.map +1 -1
- package/dist/src/render/blocks/testimonial.d.ts +5 -11
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
- package/dist/src/render/blocks/testimonial.js +9 -17
- package/dist/src/render/blocks/testimonial.js.map +1 -1
- package/dist/src/render/chrome.d.ts.map +1 -1
- package/dist/src/render/chrome.js +101 -84
- package/dist/src/render/chrome.js.map +1 -1
- package/dist/src/render/dish.d.ts +13 -0
- package/dist/src/render/dish.d.ts.map +1 -0
- package/dist/src/render/dish.js +84 -0
- package/dist/src/render/dish.js.map +1 -0
- package/dist/src/render/layout.d.ts +23 -0
- package/dist/src/render/layout.d.ts.map +1 -0
- package/dist/src/render/layout.js +33 -0
- package/dist/src/render/layout.js.map +1 -0
- package/dist/src/render/menu.d.ts +53 -0
- package/dist/src/render/menu.d.ts.map +1 -0
- package/dist/src/render/menu.js +99 -0
- package/dist/src/render/menu.js.map +1 -0
- package/dist/src/render/render-block.d.ts +19 -0
- package/dist/src/render/render-block.d.ts.map +1 -1
- package/dist/src/render/render-block.js +50 -39
- package/dist/src/render/render-block.js.map +1 -1
- package/dist/src/render/strings.d.ts +15 -0
- package/dist/src/render/strings.d.ts.map +1 -0
- package/dist/src/render/strings.js +23 -0
- package/dist/src/render/strings.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +10 -4
- package/dist/src/render/term-archive.d.ts.map +1 -1
- package/dist/src/render/term-archive.js +25 -16
- package/dist/src/render/term-archive.js.map +1 -1
- package/dist/src/render/widget-areas.d.ts +13 -0
- package/dist/src/render/widget-areas.d.ts.map +1 -0
- package/dist/src/render/widget-areas.js +20 -0
- package/dist/src/render/widget-areas.js.map +1 -0
- package/dist/theme.config.d.ts +1 -1
- package/dist/theme.config.js +2 -2
- package/dist/theme.config.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +3 -0
- package/src/render/blocks/accordion.ts +18 -47
- package/src/render/blocks/collection-list.ts +245 -133
- package/src/render/blocks/cta.ts +20 -11
- package/src/render/blocks/embed.ts +45 -36
- package/src/render/blocks/faq.ts +32 -25
- package/src/render/blocks/feature-grid.ts +83 -33
- package/src/render/blocks/gallery.ts +39 -21
- package/src/render/blocks/hero.ts +41 -34
- package/src/render/blocks/logo-strip.ts +27 -25
- package/src/render/blocks/logos.ts +34 -43
- package/src/render/blocks/media-figure.ts +49 -24
- package/src/render/blocks/pricing-table.ts +30 -42
- package/src/render/blocks/prose.ts +18 -9
- package/src/render/blocks/quote.ts +29 -19
- package/src/render/blocks/stat-counter.ts +24 -29
- package/src/render/blocks/stats.ts +30 -40
- package/src/render/blocks/testimonial.ts +17 -22
- package/src/render/chrome.ts +113 -95
- package/src/render/dish.ts +195 -0
- package/src/render/layout.ts +64 -0
- package/src/render/menu.ts +117 -0
- package/src/render/render-block.ts +51 -40
- package/src/render/strings.ts +44 -0
- package/src/render/term-archive.ts +78 -44
- package/src/render/widget-areas.ts +23 -0
- package/src/styles/archive.css +46 -56
- package/src/styles/base.css +807 -554
- package/src/styles/blocks.css +723 -641
- package/src/styles/menu.css +482 -0
- package/src/styles/theme.css +24 -25
- package/src/styles/tokens.css +239 -225
- package/src/styles/widgets.css +853 -0
- package/tokens.json +16 -16
package/src/render/chrome.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* the
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
* the header
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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)
|
|
41
|
-
|
|
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
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
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="
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}` +
|
|
101
|
-
`${
|
|
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="
|
|
110
|
-
const
|
|
111
|
-
input.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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="
|
|
126
|
-
`<div class="
|
|
127
|
-
`<a class="
|
|
128
|
-
`${tagline}
|
|
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
|
-
|
|
146
|
+
footerLinks === ''
|
|
131
147
|
? ''
|
|
132
|
-
: `<nav class="
|
|
148
|
+
: `<nav class="cr-footer__nav" aria-label="Footer"><ul class="cr-footer__links">${footerLinks}</ul></nav>`
|
|
133
149
|
}` +
|
|
134
|
-
`<div class="
|
|
135
|
-
|
|
136
|
-
`<
|
|
137
|
-
`<
|
|
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
|
+
}
|