@cogenta/theme-canonical 1.1.3 → 1.3.4

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 (71) hide show
  1. package/dist/src/index.d.ts +1 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +1 -0
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/render/arrow-link.d.ts +11 -0
  6. package/dist/src/render/arrow-link.d.ts.map +1 -0
  7. package/dist/src/render/arrow-link.js +29 -0
  8. package/dist/src/render/arrow-link.js.map +1 -0
  9. package/dist/src/render/blocks/collection-list.d.ts +15 -5
  10. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  11. package/dist/src/render/blocks/collection-list.js +36 -13
  12. package/dist/src/render/blocks/collection-list.js.map +1 -1
  13. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  14. package/dist/src/render/blocks/embed.js +3 -2
  15. package/dist/src/render/blocks/embed.js.map +1 -1
  16. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  17. package/dist/src/render/blocks/gallery.js +4 -1
  18. package/dist/src/render/blocks/gallery.js.map +1 -1
  19. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  20. package/dist/src/render/blocks/hero.js +9 -2
  21. package/dist/src/render/blocks/hero.js.map +1 -1
  22. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  23. package/dist/src/render/blocks/pricing-table.js +17 -4
  24. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  25. package/dist/src/render/chrome.d.ts +40 -1
  26. package/dist/src/render/chrome.d.ts.map +1 -1
  27. package/dist/src/render/chrome.js +103 -71
  28. package/dist/src/render/chrome.js.map +1 -1
  29. package/dist/src/render/dates.d.ts +9 -0
  30. package/dist/src/render/dates.d.ts.map +1 -0
  31. package/dist/src/render/dates.js +16 -0
  32. package/dist/src/render/dates.js.map +1 -0
  33. package/dist/src/render/render-block.d.ts.map +1 -1
  34. package/dist/src/render/render-block.js +7 -1
  35. package/dist/src/render/render-block.js.map +1 -1
  36. package/dist/src/render/strings.d.ts +25 -0
  37. package/dist/src/render/strings.d.ts.map +1 -0
  38. package/dist/src/render/strings.js +26 -0
  39. package/dist/src/render/strings.js.map +1 -0
  40. package/dist/src/render/term-archive.d.ts +5 -5
  41. package/dist/src/render/term-archive.d.ts.map +1 -1
  42. package/dist/src/render/term-archive.js +14 -12
  43. package/dist/src/render/term-archive.js.map +1 -1
  44. package/dist/src/render/widget-areas.d.ts +20 -0
  45. package/dist/src/render/widget-areas.d.ts.map +1 -0
  46. package/dist/src/render/widget-areas.js +40 -0
  47. package/dist/src/render/widget-areas.js.map +1 -0
  48. package/package.json +5 -5
  49. package/src/index.ts +1 -0
  50. package/src/render/arrow-link.ts +27 -0
  51. package/src/render/blocks/collection-list.ts +53 -15
  52. package/src/render/blocks/embed.ts +3 -2
  53. package/src/render/blocks/gallery.ts +4 -1
  54. package/src/render/blocks/hero.ts +9 -2
  55. package/src/render/blocks/pricing-table.ts +22 -4
  56. package/src/render/chrome.ts +156 -70
  57. package/src/render/dates.ts +14 -0
  58. package/src/render/render-block.ts +6 -0
  59. package/src/render/strings.ts +30 -0
  60. package/src/render/term-archive.ts +54 -32
  61. package/src/render/widget-areas.ts +44 -0
  62. package/src/styles/archive.css +66 -20
  63. package/src/styles/base.css +266 -680
  64. package/src/styles/blocks.css +678 -811
  65. package/src/styles/chrome.css +469 -0
  66. package/src/styles/listings.css +183 -0
  67. package/src/styles/theme.css +28 -14
  68. package/src/styles/tokens.css +192 -205
  69. package/src/styles/utility.css +295 -0
  70. package/src/styles/widgets.css +1064 -0
  71. package/tokens.json +15 -15
@@ -1,47 +1,80 @@
1
1
  import type { CollectionListBlock } from '@cogenta/blocks'
2
- import type { ContentEntry, RenderContext } from '../../theme-contract.js'
3
- import { entryDate, entryExcerpt, entryHref, entryTitle } from '../entry.js'
2
+ import { renderImageSource } from '@cogenta/theme-kit'
3
+ import type { ContentEntry, ImageSource, RenderContext } from '../../theme-contract.js'
4
+ import { formatEntryDate } from '../dates.js'
5
+ import { entryDate, entryExcerpt, entryHref, entryImage, entryTitle } from '../entry.js'
4
6
  import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
5
7
  import { type HtmlElement, h } from '../html.js'
6
8
 
7
9
  /**
8
- * The only block of the twelve that reads data at render time — contract B
9
- * marks it `runtime: 'server'` for that reason.
10
+ * The only block of the vocabulary that reads data at render time — contract
11
+ * B marks it `runtime: 'server'` for that reason.
10
12
  *
11
13
  * The read is *not* done here. `query` builds the request (`@cogenta/theme-kit`'s
12
14
  * `buildCollectionListQuery`, shared across every theme since it derives
13
15
  * purely from the block's own fields), the caller awaits `ctx.content.list(...)`
14
16
  * before rendering starts, and this function stays a pure function of the
15
- * entries it is handed. That keeps the markup snapshot-testable and keeps a
16
- * theme's single door to data — the read-only content client — in one visible
17
- * place.
17
+ * entries it is handed.
18
+ *
19
+ * Three layouts, three readings of the same entries:
20
+ *
21
+ * - `list` is an index: the date in its own narrow column, then the title and
22
+ * the summary. No picture: a reader scans an index by date and title.
23
+ * - `grid` sets entries in columns, each opened by its picture
24
+ * (`entryImage`, `theme@1.4`) — but only when every entry of the list has
25
+ * one. A row where one entry opens on a photograph and its neighbour on
26
+ * nothing reads as a missing image, so a list with any entry lacking a
27
+ * picture is set in type alone, every entry opening on the same rule.
28
+ * - `carousel` is the grid as a horizontal row the reader scrolls, under the
29
+ * same all-or-none rule for pictures.
18
30
  */
19
31
  export { buildCollectionListQuery as query } from '@cogenta/theme-kit'
20
32
 
21
- function renderEntry(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
33
+ function renderEntry(
34
+ entry: ContentEntry,
35
+ ctx: RenderContext,
36
+ tag: HeadingTag,
37
+ picture: ImageSource | undefined,
38
+ ): HtmlElement {
22
39
  const date = entryDate(entry)
23
40
  const excerpt = entryExcerpt(entry)
41
+ const href = entryHref(entry, ctx)
24
42
  return h(
25
43
  'li',
26
- { class: 'cg-entry' },
44
+ { class: 'cg-entry', 'data-picture': picture === undefined ? 'false' : 'true' },
27
45
  h(
28
46
  'article',
29
47
  { class: 'cg-entry__body' },
30
48
  heading(
31
49
  tag,
32
50
  { class: 'cg-entry__title' },
33
- h('a', { class: 'cg-entry__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
51
+ h('a', { class: 'cg-entry__link', href }, entryTitle(entry, ctx)),
34
52
  ),
35
53
  date === undefined
36
54
  ? null
37
55
  : h(
38
56
  'time',
39
57
  { class: 'cg-entry__date', datetime: date },
40
- // Formatted in the rendered locale rather than left as an ISO
41
- // string: the machine-readable form is already in `datetime`.
42
- new Intl.DateTimeFormat(ctx.locale, { dateStyle: 'long' }).format(new Date(date)),
58
+ // The machine-readable form is already in `datetime`.
59
+ formatEntryDate(date, ctx.locale),
43
60
  ),
44
61
  excerpt === undefined ? null : h('p', { class: 'cg-entry__excerpt' }, excerpt),
62
+ picture === undefined
63
+ ? null
64
+ : h(
65
+ 'a',
66
+ // The picture follows the title's link for a pointer, and is left
67
+ // out of the tab order and of the accessibility tree: the title
68
+ // already is the one named link to this entry.
69
+ { class: 'cg-entry__media', href, tabindex: '-1', 'aria-hidden': 'true' },
70
+ renderImageSource(
71
+ { ...picture, alt: '' },
72
+ {
73
+ className: 'cg-entry__image',
74
+ sizes: '(min-width: 72rem) 23rem, (min-width: 40rem) 50vw, 100vw',
75
+ },
76
+ ),
77
+ ),
45
78
  ),
46
79
  )
47
80
  }
@@ -53,13 +86,18 @@ export function renderCollectionList(
53
86
  ): HtmlElement {
54
87
  const hasTitle = block.title !== undefined
55
88
  const entryTag = nestedHeadingTag('collectionList', hasTitle)
89
+ const pictures =
90
+ block.layout === 'list' ? [] : entries.map((entry) => entryImage(entry, ctx, { width: 960 }))
91
+ const everyPicture = pictures.length > 0 && pictures.every((picture) => picture !== undefined)
56
92
  const items =
57
93
  entries.length === 0
58
94
  ? h('p', { class: 'cg-collection__empty' }, ctx.t('collection.empty'))
59
95
  : h(
60
96
  'ul',
61
97
  { class: 'cg-collection__items' },
62
- entries.map((entry) => renderEntry(entry, ctx, entryTag)),
98
+ entries.map((entry, index) =>
99
+ renderEntry(entry, ctx, entryTag, everyPicture ? pictures[index] : undefined),
100
+ ),
63
101
  )
64
102
 
65
103
  return h(
@@ -76,7 +114,7 @@ export function renderCollectionList(
76
114
  block.title ?? '',
77
115
  )
78
116
  : null,
79
- block.layout === 'carousel'
117
+ block.layout === 'carousel' && entries.length > 0
80
118
  ? h(
81
119
  'div',
82
120
  {
@@ -1,5 +1,6 @@
1
1
  import type { EmbedBlock } from '@cogenta/blocks'
2
2
  import type { RenderContext } from '../../theme-contract.js'
3
+ import { arrowWords } from '../arrow-link.js'
3
4
  import { type HtmlElement, h } from '../html.js'
4
5
  import { aspectRatio } from '../media.js'
5
6
 
@@ -64,11 +65,11 @@ function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): Htm
64
65
  h(
65
66
  'a',
66
67
  {
67
- class: 'cg-embed__link',
68
+ class: 'cg-arrow-link cg-embed__link',
68
69
  href: ctx.link(block.url),
69
70
  rel: 'noopener noreferrer nofollow',
70
71
  },
71
- ctx.t('embed.open', { provider: block.provider }),
72
+ arrowWords(ctx.t('embed.open', { provider: block.provider })),
72
73
  ),
73
74
  )
74
75
  }
@@ -21,7 +21,10 @@ export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElem
21
21
  h(
22
22
  'li',
23
23
  { class: 'cg-gallery__item' },
24
- image(ctx, item.media, { sizes: '(min-width: 45rem) 20rem, 60vw' }),
24
+ image(ctx, item.media, {
25
+ className: 'cg-gallery__image',
26
+ sizes: '(min-width: 64rem) 24rem, 50vw',
27
+ }),
25
28
  ),
26
29
  ),
27
30
  )
@@ -16,7 +16,13 @@ export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
16
16
  const tag = blockHeadingTag('hero') ?? 'h1'
17
17
  return h(
18
18
  'section',
19
- { class: 'cg-block cg-hero', 'data-block': 'hero' },
19
+ {
20
+ class: 'cg-block cg-hero',
21
+ 'data-block': 'hero',
22
+ // Two compositions: words alone, or words over a wide photograph. The
23
+ // attribute says which, so the stylesheet never has to guess.
24
+ 'data-media': block.media === undefined ? 'false' : 'true',
25
+ },
20
26
  h(
21
27
  'div',
22
28
  { class: 'cg-hero__body' },
@@ -39,7 +45,8 @@ export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
39
45
  // is a measured Lighthouse regression, not a theoretical one.
40
46
  image(ctx, block.media, {
41
47
  loading: 'eager',
42
- sizes: '(min-width: 60rem) 50vw, 100vw',
48
+ className: 'cg-hero__image',
49
+ sizes: '(min-width: 72rem) 72rem, 100vw',
43
50
  }),
44
51
  ),
45
52
  )
@@ -3,11 +3,17 @@ import type { RenderContext } from '../../theme-contract.js'
3
3
  import { actionLink } from '../actions.js'
4
4
  import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
5
5
  import { type HtmlElement, h } from '../html.js'
6
+ import { word } from '../strings.js'
6
7
 
7
8
  /**
8
9
  * `blocks@2.0` (RFC 0001). `highlighted` is editorial emphasis, not a colour:
9
- * it becomes `data-highlighted` for the skin to style, and `aria-current`
10
- * (loosely "the one on offer") so it is announced, not only shown.
10
+ * it becomes `data-highlighted` for the stylesheet, `aria-current` (loosely
11
+ * "the one on offer") so it is announced, and a short label under the plan's
12
+ * name so it is read in words, never only seen as a heavier rule.
13
+ *
14
+ * Each plan is four rows (name, price, features, action) that the stylesheet
15
+ * lines up across plans, so prices sit on one line and actions on another
16
+ * whatever the length of each feature list.
11
17
  */
12
18
  function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
13
19
  return h(
@@ -17,7 +23,14 @@ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): Htm
17
23
  'data-highlighted': tier.highlighted === true ? 'true' : undefined,
18
24
  'aria-current': tier.highlighted === true ? 'true' : undefined,
19
25
  },
20
- heading(tag, { class: 'cg-pricing__name' }, tier.name),
26
+ h(
27
+ 'div',
28
+ { class: 'cg-pricing__head' },
29
+ heading(tag, { class: 'cg-pricing__name' }, tier.name),
30
+ tier.highlighted === true
31
+ ? h('p', { class: 'cg-pricing__flag' }, word(ctx.locale, 'recommended'))
32
+ : null,
33
+ ),
21
34
  h(
22
35
  'p',
23
36
  { class: 'cg-pricing__price' },
@@ -38,7 +51,12 @@ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): Htm
38
51
  : h(
39
52
  'div',
40
53
  { class: 'cg-pricing__action' },
41
- actionLink(ctx, { ...tier.action, emphasis: tier.action.emphasis ?? 'primary' }),
54
+ actionLink(ctx, {
55
+ ...tier.action,
56
+ // An emphasis the editor left unset follows the plan: the
57
+ // recommended plan gets the filled button, the others an outline.
58
+ emphasis: tier.action.emphasis ?? (tier.highlighted === true ? 'primary' : 'secondary'),
59
+ }),
42
60
  ),
43
61
  )
44
62
  }
@@ -5,101 +5,187 @@ 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'
13
+ import { word } from './strings.js'
12
14
 
13
15
  /**
14
- * The site header and footer — moved here, verbatim, from what used to be
15
- * `@cogenta/cli`'s own hardcoded `<header class="cg-site-header">`/`<footer
16
- * class="cg-site-footer">` template (`packages/cli/src/commands/theme-render.ts`,
17
- * before the chrome extension point existed). Byte-identical output to that
18
- * template: this is the reference theme, and every site that installed it
19
- * before a second theme existed must render exactly as it always has.
16
+ * The site header and footer of the reference theme.
20
17
  *
21
- * A future theme with a structurally different header (a mega-menu, a cart
22
- * icon, no header at all) implements its own `renderChrome` instead — this is
23
- * simply the one this package ships.
18
+ * **Header.** One bar on the page's own ground, closed by a hairline: the
19
+ * site's name (or its uploaded logo) at the start, then the primary pages,
20
+ * then the `headerAction` (`theme@1.4`) as the page's one filled button and
21
+ * the light/dark control. The bar scrolls away with the page: a default theme
22
+ * has no reason to keep a strip of the window for itself.
24
23
  *
25
- * `theme@1.4` (L25 D2) adds four optional fields, each rendered only when
26
- * present: `headerAction` as a button-styled link at the end of the header
27
- * nav, `tagline` under the site name in the footer, `social` as an icon-link
28
- * row (`renderSocialLinks`), `footerNote` as a short "about" paragraph. A
29
- * site that sets none of them, and a `1.3` render that never reaches this
30
- * file at all, both keep the pre-1.4 header/footer byte for byte — see
31
- * `test/chrome.test.ts`'s own "without the new fields" case.
24
+ * Below the wide breakpoint the pages move into a panel under the bar, opened
25
+ * by a CSS-only control: a checkbox named by a visible "Menu" label, placed
26
+ * before the `<nav>` so the sibling combinator reaches it. No script. The
27
+ * source order (name, menu, pages, action, light/dark) is also the visual
28
+ * order at both widths, so keyboard focus never jumps around the bar.
29
+ *
30
+ * **Footer.** On a band a step off the page. The site's name, its `tagline`,
31
+ * the `footerNote` (an address or a legal mention, kept to its own lines) and
32
+ * the social profiles on the first columns; the footer menu on the last ones.
33
+ * An unlinked item of the footer menu (a `submenu-placeholder` such as
34
+ * "Company") starts a column and names it, so an editor builds the columns in
35
+ * the menu screen; a menu without one is a single column. Under a hairline,
36
+ * the legal line: the year and the site's name, then the host's
37
+ * `brandingHtml`, placed once and as received.
38
+ *
39
+ * **Footer widgets** (`theme@1.6`). The footer columns an editor fills in the
40
+ * widgets screen (`footer-1`..`footer-4`) are one row of their own, under the
41
+ * identity and the menu and above the legal line, on the same grid. A footer
42
+ * with no widget renders exactly as it did before.
43
+ *
44
+ * Every optional field renders only when present: a site that sets none of
45
+ * them gets no empty wrapper for any of them (`test/chrome.test.ts`).
32
46
  */
33
47
 
34
- function renderNavLinks(links: readonly ChromeNavLink[]): string {
35
- if (links.length === 0) return ''
36
- const items = links
37
- .filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
38
- .map((link) => {
39
- const label = escapeText(link.label)
40
- const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
41
- if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
42
- const href = escapeAttribute(link.href)
43
- const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
44
- return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
45
- })
46
- .join('')
47
- return items === '' ? '' : `<ul class="cg-menu">${items}</ul>`
48
+ interface FooterGroup {
49
+ readonly heading: string | null
50
+ readonly links: readonly ChromeNavLink[]
48
51
  }
49
52
 
50
- /** The header's own call-to-action link (`theme@1.4`), styled like the block vocabulary's own primary action. */
51
- function renderHeaderAction(action: ChromeInput['headerAction']): string {
52
- if (action === undefined) return ''
53
- return (
54
- `<a class="cg-action cg-site-header__action" data-emphasis="primary" ` +
55
- `href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
56
- )
53
+ /** Links a visitor can follow, plus the unlinked headings that name a column. */
54
+ function usable(links: readonly ChromeNavLink[]): readonly ChromeNavLink[] {
55
+ return links.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
57
56
  }
58
57
 
59
- export function renderChrome(input: ChromeInput): ChromeResult {
60
- const siteName = escapeAttribute(input.site.name)
61
- const headerNav = renderNavLinks(input.headerNav)
62
- const footerNav = renderNavLinks(input.footerNav)
58
+ function linkItem(link: ChromeNavLink): string {
59
+ const label = escapeText(link.label)
60
+ const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
61
+ if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
62
+ const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
63
+ return `<li><a href="${escapeAttribute(link.href)}"${target}${titleAttr}>${label}</a></li>`
64
+ }
65
+
66
+ /** The footer menu, split into columns at each unlinked heading. */
67
+ export function footerGroups(links: readonly ChromeNavLink[]): readonly FooterGroup[] {
68
+ const groups: { heading: string | null; links: ChromeNavLink[] }[] = []
69
+ for (const link of usable(links)) {
70
+ if (link.kind === 'submenu-placeholder' && link.href === null) {
71
+ groups.push({ heading: link.label, links: [] })
72
+ continue
73
+ }
74
+ const current = groups[groups.length - 1]
75
+ if (current === undefined) groups.push({ heading: null, links: [link] })
76
+ else current.links.push(link)
77
+ }
78
+ return groups.filter((group) => group.links.length > 0)
79
+ }
80
+
81
+ /** `footerNote` as paragraphs at blank lines, each keeping its own line breaks. */
82
+ function renderFooterNote(note: string): string {
83
+ const paragraphs = note
84
+ .replace(/\r\n?/g, '\n')
85
+ .split(/\n\s*\n/)
86
+ .map((paragraph) =>
87
+ paragraph
88
+ .split('\n')
89
+ .map((line) => line.trim())
90
+ .filter((line) => line !== ''),
91
+ )
92
+ .filter((lines) => lines.length > 0)
93
+ if (paragraphs.length === 0) return ''
94
+ return `<div class="cg-site-footer__about">${paragraphs
95
+ .map(
96
+ (lines) =>
97
+ `<p class="cg-site-footer__note">${lines.map((line) => escapeText(line)).join('<br>')}</p>`,
98
+ )
99
+ .join('')}</div>`
100
+ }
101
+
102
+ function renderHeader(input: ChromeInput): string {
103
+ const siteName = escapeText(input.site.name)
104
+ const home = escapeAttribute(input.homeHref)
63
105
  // The uploaded logo replaces the wordmark, and only the wordmark: the
64
- // footer keeps the site's name in text, so a site whose logo fails to load
65
- // is still named somewhere on every page.
106
+ // footer keeps the site's name in text, so a page whose logo fails to load
107
+ // still names the site.
66
108
  const mark = renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ?? siteName
67
- const headerAction = renderHeaderAction(input.headerAction)
68
- const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
109
+ const links = usable(input.headerNav).map(linkItem).join('')
110
+ const actionHref =
111
+ input.headerAction === undefined ? '' : escapeAttribute(input.headerAction.href)
112
+ const actionLabel = input.headerAction === undefined ? '' : escapeText(input.headerAction.label)
113
+ // On a phone the bar holds the name, the menu control and the light/dark
114
+ // control; the action moves to the foot of the menu panel. It is the same
115
+ // link twice in the source, and the stylesheet shows exactly one of them at
116
+ // any width (`display: none` hides the other from assistive technology
117
+ // too), so it is never announced twice.
118
+ const menuAction =
119
+ input.headerAction === undefined
120
+ ? ''
121
+ : `<p class="cg-site-header__menu-action"><a class="cg-action" data-emphasis="primary" href="${actionHref}">${actionLabel}</a></p>`
122
+ const menu =
123
+ links === ''
124
+ ? ''
125
+ : `<input type="checkbox" id="cg-nav-toggle" class="cg-nav-toggle" aria-controls="cg-site-nav">` +
126
+ `<label for="cg-nav-toggle" class="cg-nav-toggle__label">${escapeText(word(input.locale, 'menu'))}</label>` +
127
+ `<nav class="cg-site-header__nav" id="cg-site-nav" aria-label="${escapeAttribute(
128
+ word(input.locale, 'primaryNavigation'),
129
+ )}"><ul class="cg-menu">${links}</ul>${menuAction}</nav>`
130
+ const action =
131
+ input.headerAction === undefined
132
+ ? ''
133
+ : `<a class="cg-action cg-site-header__action" data-emphasis="primary" href="${actionHref}">${actionLabel}</a>`
134
+ const toggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
69
135
 
70
- const header =
71
- `<header class="cg-site-header"><div class="cg-site-header__inner">` +
72
- `<a class="cg-site-header__home" href="${escapeAttribute(input.homeHref)}">${mark}</a>` +
73
- `${headerNav === '' ? '' : `<nav class="cg-site-header__nav" aria-label="Primary">${headerNav}</nav>`}` +
74
- `${themeToggle}` +
75
- `${headerAction}` +
136
+ return (
137
+ `<header class="cg-site-header" data-nav="${links === '' ? 'none' : 'links'}"><div class="cg-site-header__inner">` +
138
+ `<a class="cg-site-header__home" href="${home}">${mark}</a>` +
139
+ menu +
140
+ `<div class="cg-site-header__end">${action}${toggle}</div>` +
76
141
  `</div></header>`
142
+ )
143
+ }
77
144
 
145
+ function renderFooter(input: ChromeInput): string {
146
+ const siteName = escapeText(input.site.name)
78
147
  const tagline =
79
148
  input.tagline === undefined
80
149
  ? ''
81
150
  : `<p class="cg-site-footer__tagline">${escapeText(input.tagline)}</p>`
82
- const social =
83
- input.social === undefined
151
+ const note = input.footerNote === undefined ? '' : renderFooterNote(input.footerNote)
152
+ const socialLinks = renderSocialLinks(input.social, {
153
+ className: 'cg-site-footer__social',
154
+ itemClassName: 'cg-site-footer__social-item',
155
+ })
156
+ const social = socialLinks === null ? '' : serialize(socialLinks)
157
+ const groups = footerGroups(input.footerNav)
158
+ const nav =
159
+ groups.length === 0
84
160
  ? ''
85
- : serialize(
86
- renderSocialLinks(input.social, {
87
- className: 'cg-site-footer__social',
88
- itemClassName: 'cg-site-footer__social-item',
89
- }) ?? { kind: 'text', value: '' },
90
- )
91
- const footerNote =
92
- input.footerNote === undefined
93
- ? ''
94
- : `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
161
+ : `<nav class="cg-site-footer__nav" aria-label="${escapeAttribute(
162
+ word(input.locale, 'footerNavigation'),
163
+ )}" data-columns="${Math.min(groups.length, 3)}">${groups
164
+ .map(
165
+ (group) =>
166
+ `<div class="cg-site-footer__group">${
167
+ group.heading === null
168
+ ? ''
169
+ : `<p class="cg-site-footer__heading">${escapeText(group.heading)}</p>`
170
+ }<ul class="cg-menu">${group.links.map(linkItem).join('')}</ul></div>`,
171
+ )
172
+ .join('')}</nav>`
173
+ const widgets = renderFooterWidgets(input.widgets, {
174
+ className: 'cg-site-footer__widgets',
175
+ headingLevel: 'h2',
176
+ })
177
+ const year = new Date().getFullYear()
95
178
 
96
- const footer =
179
+ return (
97
180
  `<footer class="cg-site-footer"><div class="cg-site-footer__inner">` +
98
- `<span>${siteName}</span>${tagline}` +
99
- `${footerNav === '' ? '' : `<nav class="cg-site-footer__nav" aria-label="Footer">${footerNav}</nav>`}` +
100
- `${social}` +
101
- `${footerNote === '' ? '' : `<div class="cg-site-footer__about">${footerNote}</div>`}` +
102
- `${input.brandingHtml}</div></footer>`
181
+ `<div class="cg-site-footer__identity"><p class="cg-site-footer__name">${siteName}</p>${tagline}${note}${social}</div>` +
182
+ nav +
183
+ (widgets === null ? '' : serialize(widgets)) +
184
+ `<div class="cg-site-footer__legal"><p class="cg-site-footer__copyright">© ${year} ${siteName}</p>${input.brandingHtml}</div>` +
185
+ `</div></footer>`
186
+ )
187
+ }
103
188
 
104
- return { header, footer }
189
+ export function renderChrome(input: ChromeInput): ChromeResult {
190
+ return { header: renderHeader(input), footer: renderFooter(input) }
105
191
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The one date format of this theme — `Intl`'s long date, in the page's own
3
+ * language — shared by the collection list, the term archive and the entry
4
+ * header (`renderEntryHeader` formats the same way), so a date reads alike on
5
+ * every page. An invalid locale or date falls back to the ISO day, a true
6
+ * answer rather than a thrown error.
7
+ */
8
+ export function formatEntryDate(iso: string, locale: string): string {
9
+ try {
10
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(new Date(iso))
11
+ } catch {
12
+ return iso.slice(0, 10)
13
+ }
14
+ }
@@ -3,6 +3,7 @@ import {
3
3
  type FetchedEntries,
4
4
  type PageContent,
5
5
  pageHasOwnHeading,
6
+ providedBlockNode,
6
7
  renderEntryHeader,
7
8
  resolveBlockForRender,
8
9
  withBlockKey,
@@ -43,6 +44,11 @@ export function renderBlock(
43
44
  entries: FetchedEntries = {},
44
45
  registry?: BlockRegistry,
45
46
  ): HtmlElement | null {
47
+ // A block a plugin provides arrives already rendered by the host, in a
48
+ // process of its own (contract D theme@1.7). Nothing else in this theme
49
+ // needs to know such blocks exist.
50
+ const provided = providedBlockNode(block, ctx)
51
+ if (provided !== null) return withBlockVariant(provided, block.variant)
46
52
  // `block`'s type says `VocabularyBlock`, but the value crossing this
47
53
  // boundary from stored content is not always literally one of the twelve —
48
54
  // `theme-render.ts` cannot know at compile time what a block zone holds.
@@ -0,0 +1,30 @@
1
+ import { themeLocaleFor } from '@cogenta/theme-kit'
2
+
3
+ /**
4
+ * The few words this theme prints on its own, beyond `THEME_STRINGS`
5
+ * (`@cogenta/theme-kit`): the name of the menu control and of the two
6
+ * navigation landmarks, and the label over a recommended plan. The same two
7
+ * languages as the shared table, resolved the same way (`fr-CA` is French,
8
+ * anything unknown is English).
9
+ */
10
+
11
+ const WORDS = {
12
+ en: {
13
+ menu: 'Menu',
14
+ primaryNavigation: 'Primary',
15
+ footerNavigation: 'Footer',
16
+ recommended: 'Recommended',
17
+ },
18
+ fr: {
19
+ menu: 'Menu',
20
+ primaryNavigation: 'Principale',
21
+ footerNavigation: 'Pied de page',
22
+ recommended: 'Recommandé',
23
+ },
24
+ } as const
25
+
26
+ export type ThemeWord = keyof (typeof WORDS)['en']
27
+
28
+ export function word(locale: string, key: ThemeWord): string {
29
+ return WORDS[themeLocaleFor(locale)][key]
30
+ }