@cogenta/theme-magazine 1.1.3 → 1.3.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 (126) 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/article.d.ts +32 -0
  6. package/dist/src/render/article.d.ts.map +1 -0
  7. package/dist/src/render/article.js +137 -0
  8. package/dist/src/render/article.js.map +1 -0
  9. package/dist/src/render/blocks/accordion.d.ts.map +1 -1
  10. package/dist/src/render/blocks/accordion.js +9 -13
  11. package/dist/src/render/blocks/accordion.js.map +1 -1
  12. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  13. package/dist/src/render/blocks/collection-list.js +91 -118
  14. package/dist/src/render/blocks/collection-list.js.map +1 -1
  15. package/dist/src/render/blocks/cta.d.ts +4 -4
  16. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  17. package/dist/src/render/blocks/cta.js +7 -9
  18. package/dist/src/render/blocks/cta.js.map +1 -1
  19. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  20. package/dist/src/render/blocks/embed.js +17 -20
  21. package/dist/src/render/blocks/embed.js.map +1 -1
  22. package/dist/src/render/blocks/faq.d.ts.map +1 -1
  23. package/dist/src/render/blocks/faq.js +12 -11
  24. package/dist/src/render/blocks/faq.js.map +1 -1
  25. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  26. package/dist/src/render/blocks/feature-grid.js +13 -20
  27. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  28. package/dist/src/render/blocks/gallery.d.ts +6 -6
  29. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  30. package/dist/src/render/blocks/gallery.js +15 -13
  31. package/dist/src/render/blocks/gallery.js.map +1 -1
  32. package/dist/src/render/blocks/hero.d.ts +6 -5
  33. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  34. package/dist/src/render/blocks/hero.js +15 -14
  35. package/dist/src/render/blocks/hero.js.map +1 -1
  36. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
  37. package/dist/src/render/blocks/logo-strip.js +8 -11
  38. package/dist/src/render/blocks/logo-strip.js.map +1 -1
  39. package/dist/src/render/blocks/logos.d.ts.map +1 -1
  40. package/dist/src/render/blocks/logos.js +16 -16
  41. package/dist/src/render/blocks/logos.js.map +1 -1
  42. package/dist/src/render/blocks/media-figure.d.ts +8 -8
  43. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  44. package/dist/src/render/blocks/media-figure.js +23 -19
  45. package/dist/src/render/blocks/media-figure.js.map +1 -1
  46. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  47. package/dist/src/render/blocks/pricing-table.js +19 -22
  48. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  49. package/dist/src/render/blocks/prose.d.ts +6 -10
  50. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  51. package/dist/src/render/blocks/prose.js +8 -11
  52. package/dist/src/render/blocks/prose.js.map +1 -1
  53. package/dist/src/render/blocks/quote.d.ts +6 -9
  54. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  55. package/dist/src/render/blocks/quote.js +11 -13
  56. package/dist/src/render/blocks/quote.js.map +1 -1
  57. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
  58. package/dist/src/render/blocks/stat-counter.js +9 -10
  59. package/dist/src/render/blocks/stat-counter.js.map +1 -1
  60. package/dist/src/render/blocks/stats.d.ts.map +1 -1
  61. package/dist/src/render/blocks/stats.js +9 -12
  62. package/dist/src/render/blocks/stats.js.map +1 -1
  63. package/dist/src/render/blocks/testimonial.d.ts +4 -13
  64. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  65. package/dist/src/render/blocks/testimonial.js +7 -15
  66. package/dist/src/render/blocks/testimonial.js.map +1 -1
  67. package/dist/src/render/chrome.d.ts.map +1 -1
  68. package/dist/src/render/chrome.js +109 -97
  69. package/dist/src/render/chrome.js.map +1 -1
  70. package/dist/src/render/layout.d.ts +28 -0
  71. package/dist/src/render/layout.d.ts.map +1 -0
  72. package/dist/src/render/layout.js +72 -0
  73. package/dist/src/render/layout.js.map +1 -0
  74. package/dist/src/render/render-block.d.ts +15 -13
  75. package/dist/src/render/render-block.d.ts.map +1 -1
  76. package/dist/src/render/render-block.js +53 -29
  77. package/dist/src/render/render-block.js.map +1 -1
  78. package/dist/src/render/story.d.ts +41 -0
  79. package/dist/src/render/story.d.ts.map +1 -0
  80. package/dist/src/render/story.js +65 -0
  81. package/dist/src/render/story.js.map +1 -0
  82. package/dist/src/render/term-archive.d.ts +0 -10
  83. package/dist/src/render/term-archive.d.ts.map +1 -1
  84. package/dist/src/render/term-archive.js +42 -19
  85. package/dist/src/render/term-archive.js.map +1 -1
  86. package/dist/src/render/widget-areas.d.ts +12 -0
  87. package/dist/src/render/widget-areas.d.ts.map +1 -0
  88. package/dist/src/render/widget-areas.js +19 -0
  89. package/dist/src/render/widget-areas.js.map +1 -0
  90. package/dist/theme.config.js +2 -2
  91. package/dist/theme.config.js.map +1 -1
  92. package/package.json +5 -5
  93. package/src/index.ts +3 -0
  94. package/src/render/article.ts +239 -0
  95. package/src/render/blocks/accordion.ts +23 -30
  96. package/src/render/blocks/collection-list.ts +161 -225
  97. package/src/render/blocks/cta.ts +19 -13
  98. package/src/render/blocks/embed.ts +40 -36
  99. package/src/render/blocks/faq.ts +31 -36
  100. package/src/render/blocks/feature-grid.ts +26 -36
  101. package/src/render/blocks/gallery.ts +24 -18
  102. package/src/render/blocks/hero.ts +32 -32
  103. package/src/render/blocks/logo-strip.ts +17 -17
  104. package/src/render/blocks/logos.ts +23 -31
  105. package/src/render/blocks/media-figure.ts +36 -20
  106. package/src/render/blocks/pricing-table.ts +29 -34
  107. package/src/render/blocks/prose.ts +13 -13
  108. package/src/render/blocks/quote.ts +24 -24
  109. package/src/render/blocks/stat-counter.ts +16 -24
  110. package/src/render/blocks/stats.ts +14 -24
  111. package/src/render/blocks/testimonial.ts +12 -17
  112. package/src/render/chrome.ts +123 -104
  113. package/src/render/layout.ts +105 -0
  114. package/src/render/render-block.ts +61 -33
  115. package/src/render/story.ts +139 -0
  116. package/src/render/term-archive.ts +111 -58
  117. package/src/render/widget-areas.ts +22 -0
  118. package/src/styles/archive.css +114 -17
  119. package/src/styles/article.css +237 -0
  120. package/src/styles/base.css +848 -591
  121. package/src/styles/blocks.css +903 -921
  122. package/src/styles/stories.css +593 -0
  123. package/src/styles/theme.css +28 -9
  124. package/src/styles/tokens.css +200 -185
  125. package/src/styles/widgets.css +614 -0
  126. package/tokens.json +18 -18
@@ -5,48 +5,44 @@ 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
- * The masthead. A magazine's front matter is a nameplate — the title set in
15
- * the display serif, centred, with a hairline rule above and below — and a
16
- * quiet rubric bar underneath it, the way a print contents strip reads. That
17
- * is a structurally different header from a left-aligned wordmark plus a
18
- * right-aligned menu (`theme-canonical`'s own layout): the nameplate gets
19
- * its own row so it can be set large without competing with the nav for
20
- * width, and the nav becomes a second, quieter row.
15
+ * The masthead and the colophon (`theme@1.4`), drawn the way a daily draws
16
+ * them.
21
17
  *
22
- * L25 pro pass (`theme@1.4`): the top strip now names today's date rather
23
- * than a static tagline (`Intl.DateTimeFormat(ctx.locale, { dateStyle:
24
- * 'full' })` on `new Date()` — legitimate here because every page renders
25
- * per request, `cogenta serve` never caches a rendered page across a day
26
- * boundary for an anonymous visitor beyond `security.pageMaxAge`, and this
27
- * theme's own masthead has never claimed to be static-generated — `theme.
28
- * config.ts` still declares `runtime: 'static'`, which describes what this
29
- * theme *needs* to render a page, not a promise that today's date is frozen
30
- * at build time); the rubric row now also carries `headerAction` as a filled
31
- * "Subscribe"-style button; and a CSS-only `<details>` disclosure collapses
32
- * the whole rubric row into a hamburger below the nameplate's own
33
- * breakpoint, exactly the technique `theme-blog`'s masthead already proved
34
- * against a real browser (a closed `<details>`'s non-summary content cannot
35
- * be forced open by CSS alone, so the collapsed and expanded nav are two
36
- * separate `<nav>`s, never one pushed into a state it was never opened
37
- * into — see that theme's own `chrome.ts` for the verified detail).
18
+ * Masthead, three rows:
38
19
  *
39
- * L26 D4 — the manual light/dark/system toggle (`renderThemeToggle`,
40
- * `@cogenta/theme-kit`) sits in the top strip, next to today's date, not in
41
- * the rubric row below: the top strip renders unconditionally on every
42
- * page, while the rubric (`hasRubric`) only renders when there is a nav or
43
- * a header action — a toggle placed there would vanish on a site with
44
- * neither, which is exactly the "dark mode looks unsupported" bug this
45
- * feature exists to fix.
20
+ * 1. A thin bar: today's date on the left, the `tagline` in the middle on a
21
+ * wide screen, the `headerAction` and the light/dark control on the right.
22
+ * The action lives here rather than in the navigation so it stays visible
23
+ * on a phone with the menu closed.
24
+ * 2. The nameplate: the site's name in the display face, centred, or its
25
+ * uploaded logo at nameplate scale.
26
+ * 3. The section navigation in spaced capitals between a double rule and a
27
+ * hairline. Below the breakpoint it becomes a panel opened by a CSS-only
28
+ * toggle, a visually hidden checkbox paired with a `<label>`, no
29
+ * `<script>`. The checkbox and label are siblings placed before the
30
+ * `<nav>` so the sibling combinator in `base.css` reaches it.
31
+ *
32
+ * The date is the date the page is rendered, which is what a masthead date
33
+ * means on a site rendered per request.
34
+ *
35
+ * Colophon: the name again on a double rule, then four columns (the tagline
36
+ * and `footerNote`, the footer navigation over two columns, the social
37
+ * profiles with their names), then the legal line: the copyright year and the
38
+ * site's name beside Cogenta's credit (`brandingHtml`, placed once, as
39
+ * received). No column carries a heading: a heading is a word, and a theme
40
+ * has no translation for it. Every `theme@1.4` field renders only when
41
+ * present, so a host that predates them still gets the name and navigation.
46
42
  */
47
43
 
48
- function renderNavItems(links: readonly ChromeNavLink[], className: string): string {
49
- const items = links
44
+ function navItems(links: readonly ChromeNavLink[]): string {
45
+ return links
50
46
  .filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
51
47
  .map((link) => {
52
48
  const label = escapeText(link.label)
@@ -57,103 +53,126 @@ function renderNavItems(links: readonly ChromeNavLink[], className: string): str
57
53
  return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
58
54
  })
59
55
  .join('')
60
- return items === '' ? '' : `<ul class="${className}">${items}</ul>`
61
- }
62
-
63
- /** The rubric row's own call-to-action link (`theme@1.4`) — a filled button, the masthead's one place of solid colour. */
64
- function renderHeaderAction(action: ChromeInput['headerAction']): string {
65
- if (action === undefined) return ''
66
- return (
67
- `<a class="cg-action cg-masthead__action" data-emphasis="primary" ` +
68
- `href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
69
- )
70
56
  }
71
57
 
72
- /** A hamburger mark drawn from three stacked bars — no icon font, no glyph a font might not ship. */
73
- function menuGlyph(): string {
74
- return '<span class="cg-masthead__bars" aria-hidden="true"></span>'
58
+ function isoDay(date: Date): string {
59
+ return date.toISOString().slice(0, 10)
75
60
  }
76
61
 
77
- function todaysDate(locale: string): string {
62
+ function formatToday(date: Date, locale: string, options: Intl.DateTimeFormatOptions): string {
78
63
  try {
79
- return new Intl.DateTimeFormat(locale, { dateStyle: 'full' }).format(new Date())
64
+ return new Intl.DateTimeFormat(locale, options).format(date)
80
65
  } catch {
81
- return new Date().toISOString().slice(0, 10)
66
+ return new Intl.DateTimeFormat('en', options).format(date)
82
67
  }
83
68
  }
84
69
 
70
+ /** `footerNote` as paragraphs: a blank line starts a new one. */
71
+ function renderFooterNote(note: string): string {
72
+ const paragraphs = note
73
+ .replace(/\r\n?/g, '\n')
74
+ .split(/\n\s*\n/)
75
+ .map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ').trim())
76
+ .filter((paragraph) => paragraph !== '')
77
+ if (paragraphs.length === 0) return ''
78
+ return `<div class="cg-colophon__note">${paragraphs
79
+ .map((paragraph) => `<p>${escapeText(paragraph)}</p>`)
80
+ .join('')}</div>`
81
+ }
82
+
85
83
  export function renderChrome(input: ChromeInput): ChromeResult {
86
84
  const siteNameText = escapeText(input.site.name)
87
- const navItems = renderNavItems(input.headerNav, 'cg-masthead__menu')
88
- const headerAction = renderHeaderAction(input.headerAction)
89
- const footerNav = renderNavItems(input.footerNav, 'cg-colophon__menu')
90
85
  const homeHref = escapeAttribute(input.homeHref)
91
- // A nameplate is the one place a masthead earns a logo: it replaces the
92
- // wordmark on its own row. The colophon keeps the name in text, so the
93
- // site is still named in a page whose images never load.
94
- const nameplate = renderBrandMark(input.brand, { className: 'cg-masthead__logo' }) ?? siteNameText
95
- const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
86
+ const sections = navItems(input.headerNav)
87
+ const today = new Date()
88
+ const longToday = escapeText(
89
+ formatToday(today, input.locale, {
90
+ weekday: 'long',
91
+ year: 'numeric',
92
+ month: 'long',
93
+ day: 'numeric',
94
+ }),
95
+ )
96
+ const shortToday = escapeText(
97
+ formatToday(today, input.locale, { weekday: 'short', month: 'short', day: 'numeric' }),
98
+ )
99
+ const plate =
100
+ renderBrandMark(input.brand, { className: 'cg-masthead__logo' }) ??
101
+ `<span class="cg-masthead__name">${siteNameText}</span>`
102
+ const action =
103
+ input.headerAction === undefined
104
+ ? ''
105
+ : `<a class="cg-action cg-masthead__action" data-emphasis="masthead" href="${escapeAttribute(
106
+ input.headerAction.href,
107
+ )}">${escapeText(input.headerAction.label)}</a>`
108
+ const toggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
109
+ const tagline =
110
+ input.tagline === undefined
111
+ ? ''
112
+ : `<p class="cg-masthead__tagline">${escapeText(input.tagline)}</p>`
96
113
 
97
- const hasRubric = navItems !== '' || headerAction !== ''
98
- // Two renderings of the same rubric row, never both visible at once (CSS,
99
- // `min-width: 56rem`) — see the file-level comment for why this cannot be
100
- // a single `<details open>` forced open at width.
101
- const mobileRubric = !hasRubric
102
- ? ''
103
- : `<details class="cg-masthead__disclosure">` +
104
- `<summary class="cg-masthead__toggle" aria-label="Menu">${menuGlyph()}</summary>` +
105
- `<nav class="cg-masthead__panel" aria-label="Primary">${navItems}${headerAction}</nav>` +
106
- `</details>`
107
- const desktopRubric = !hasRubric
108
- ? ''
109
- : `<nav class="cg-masthead__nav" aria-label="Primary"><div class="cg-masthead__nav-inner">${navItems}${headerAction}</div></nav>`
114
+ const menu =
115
+ sections === ''
116
+ ? ''
117
+ : `<input type="checkbox" id="cg-nav-toggle" class="cg-nav-toggle-input" aria-label="Menu">` +
118
+ `<label for="cg-nav-toggle" class="cg-nav-toggle-label" aria-hidden="true">` +
119
+ `<span class="cg-nav-toggle-bar"></span><span class="cg-nav-toggle-bar"></span>` +
120
+ `</label>` +
121
+ `<nav class="cg-masthead__nav" id="cg-nav" aria-label="Primary">` +
122
+ `<ul class="cg-masthead__sections">${sections}</ul>` +
123
+ `</nav>`
110
124
 
111
125
  const header =
112
- `<header class="cg-masthead">` +
113
- `<div class="cg-masthead__top">` +
114
- `<span class="cg-masthead__date">${escapeText(todaysDate(input.locale))}</span>` +
115
- `${themeToggle}` +
126
+ `<header class="cg-masthead" data-nav="${sections === '' ? 'none' : 'sections'}">` +
127
+ `<div class="cg-masthead__bar"><div class="cg-masthead__bar-inner">` +
128
+ `<p class="cg-masthead__date"><time datetime="${isoDay(today)}">` +
129
+ `<span class="cg-masthead__date-long">${longToday}</span>` +
130
+ `<span class="cg-masthead__date-short" aria-hidden="true">${shortToday}</span>` +
131
+ `</time></p>` +
132
+ `${tagline}` +
133
+ `<div class="cg-masthead__tools">${action}${toggle}</div>` +
134
+ `</div></div>` +
135
+ `<div class="cg-masthead__plate">` +
136
+ `<a class="cg-masthead__home" href="${homeHref}">${plate}</a>` +
116
137
  `</div>` +
117
- `<div class="cg-masthead__nameplate">` +
118
- `<a class="cg-masthead__wordmark" href="${homeHref}">${nameplate}</a>` +
119
- `${mobileRubric}` +
120
- `</div>` +
121
- `${desktopRubric}` +
138
+ `${menu}` +
122
139
  `</header>`
123
140
 
124
- const tagline =
141
+ const footerTagline =
125
142
  input.tagline === undefined
126
143
  ? ''
127
144
  : `<p class="cg-colophon__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
128
- const social =
129
- input.social === undefined
130
- ? ''
131
- : serialize(
132
- renderSocialLinks(input.social, {
133
- className: 'cg-colophon__social',
134
- itemClassName: 'cg-colophon__social-item',
135
- }) ?? { kind: 'text', value: '' },
136
- )
137
- const footerNote =
138
- input.footerNote === undefined
139
- ? ''
140
- : `<p class="cg-colophon__note">${escapeText(input.footerNote)}</p>`
145
+ const note = input.footerNote === undefined ? '' : renderFooterNote(input.footerNote)
146
+ const footerLinks = navItems(input.footerNav)
147
+ const social = renderSocialLinks(input.social, {
148
+ className: 'cg-colophon__social',
149
+ itemClassName: 'cg-colophon__social-item',
150
+ })
151
+ const year = today.getFullYear()
152
+ // The footer widget columns (`theme@1.6`) sit under the name, above the
153
+ // colophon's own columns, set in the same register.
154
+ const footerWidgets = renderFooterWidgets(input.widgets, {
155
+ className: 'cg-colophon__widgets',
156
+ headingLevel: 'h2',
157
+ })
141
158
 
142
159
  const footer =
143
- `<footer class="cg-colophon"><div class="cg-colophon__grid">` +
144
- `<div class="cg-colophon__about">` +
145
- `<a class="cg-colophon__wordmark" href="${homeHref}">${siteNameText}</a>` +
146
- `${tagline}</div>` +
147
- `<div class="cg-colophon__sections">` +
148
- `<p class="cg-colophon__heading">Sections</p>` +
149
- `${footerNav === '' ? '' : `<nav aria-label="Footer">${footerNav}</nav>`}` +
160
+ `<footer class="cg-colophon"><div class="cg-colophon__inner">` +
161
+ `<div class="cg-colophon__plate">` +
162
+ `<a class="cg-colophon__name" href="${homeHref}">${siteNameText}</a>` +
150
163
  `</div>` +
151
- `<div class="cg-colophon__follow">` +
152
- `<p class="cg-colophon__heading">Follow</p>` +
153
- `${social}` +
164
+ `${footerWidgets === null ? '' : serialize(footerWidgets)}` +
165
+ `<div class="cg-colophon__grid">` +
166
+ `${footerTagline === '' && note === '' ? '' : `<div class="cg-colophon__about">${footerTagline}${note}</div>`}` +
167
+ `${
168
+ footerLinks === ''
169
+ ? ''
170
+ : `<nav class="cg-colophon__nav" aria-label="Footer"><ul class="cg-colophon__links">${footerLinks}</ul></nav>`
171
+ }` +
172
+ `${social === null ? '' : `<div class="cg-colophon__follow">${serialize(social)}</div>`}` +
154
173
  `</div>` +
155
- `<div class="cg-colophon__meta">` +
156
- `${footerNote}` +
174
+ `<div class="cg-colophon__legal">` +
175
+ `<p class="cg-colophon__copyright">© ${year} ${siteNameText}</p>` +
157
176
  `<div class="cg-colophon__branding">${input.brandingHtml}</div>` +
158
177
  `</div>` +
159
178
  `</div></footer>`
@@ -0,0 +1,105 @@
1
+ import {
2
+ type Attributes,
3
+ blockHeadingTag,
4
+ type Child,
5
+ type ContentEntry,
6
+ type HtmlElement,
7
+ h,
8
+ heading,
9
+ } from '@cogenta/theme-kit'
10
+
11
+ /**
12
+ * The frame every block of this theme renders into, and the typographic
13
+ * furniture the blocks share.
14
+ *
15
+ * The outer element carries the section rhythm (`--cg-section`, applied once
16
+ * to every block alike) and paints edge to edge when a variant asks for a
17
+ * band. The inner element is the twelve-column container: a front page, a
18
+ * rail, an article and a colophon all sit on the same columns, so the left
19
+ * edge of a headline, a standfirst and a caption never drift apart.
20
+ *
21
+ * `inner` is `figure` for a block whose semantics are a figure (a quotation
22
+ * and its attribution, a photograph and its caption): `<figcaption>` must be
23
+ * a direct child of its `<figure>`.
24
+ */
25
+ export function section(
26
+ outer: 'section' | 'div',
27
+ block: string,
28
+ className: string,
29
+ attrs: Attributes,
30
+ inner: 'div' | 'figure',
31
+ ...children: readonly Child[]
32
+ ): HtmlElement {
33
+ return h(
34
+ outer,
35
+ { class: `cg-section ${className}`, 'data-block': block, ...attrs },
36
+ h(inner, { class: `cg-container ${className}__inner` }, ...children),
37
+ )
38
+ }
39
+
40
+ /**
41
+ * A titled block's head, set the way a newspaper sets a section label: a
42
+ * heavy rule in ink across the grid, the title in the display face under it.
43
+ * `null` for an untitled block, so a caller can drop it into its children.
44
+ */
45
+ export function sectionHead(blockName: string, title: string | undefined): HtmlElement | null {
46
+ if (title === undefined) return null
47
+ return h(
48
+ 'div',
49
+ { class: 'cg-head' },
50
+ heading(
51
+ blockHeadingTag(blockName) ?? 'h2',
52
+ { class: 'cg-head__title', 'data-field': 'title' },
53
+ title,
54
+ ),
55
+ )
56
+ }
57
+
58
+ function parse(iso: string): Date | null {
59
+ const date = new Date(iso)
60
+ return Number.isNaN(date.getTime()) ? null : date
61
+ }
62
+
63
+ function format(date: Date, locale: string, options: Intl.DateTimeFormatOptions): string {
64
+ try {
65
+ return new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options }).format(date)
66
+ } catch {
67
+ return new Intl.DateTimeFormat('en', { timeZone: 'UTC', ...options }).format(date)
68
+ }
69
+ }
70
+
71
+ /** `September 13, 2026` in the page's locale, or `null` when the value is not a date. */
72
+ export function longDate(iso: string, locale: string): string | null {
73
+ const date = parse(iso)
74
+ return date === null ? null : format(date, locale, { dateStyle: 'long' })
75
+ }
76
+
77
+ /** `Sep 13, 2026`, the form a listing sets under a headline, or `null`. */
78
+ export function shortDate(iso: string, locale: string): string | null {
79
+ const date = parse(iso)
80
+ return date === null ? null : format(date, locale, { dateStyle: 'medium' })
81
+ }
82
+
83
+ /**
84
+ * A taxonomy field stores a term id, never a label: a value shaped like one
85
+ * is not something a reader can be shown.
86
+ */
87
+ const TERM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
88
+
89
+ /**
90
+ * The label a listing sets above a headline, read from whichever of the usual
91
+ * field names a collection declares: `kicker` first (a topic, a form such as
92
+ * "Review", a columnist's name), then the plain-text section fields older
93
+ * schemas use. Never invented, and never a term id.
94
+ */
95
+ const KICKER_FIELDS = ['kicker', 'rubric', 'section', 'category', 'topic', 'department'] as const
96
+
97
+ export function entryKicker(entry: ContentEntry): string | undefined {
98
+ for (const field of KICKER_FIELDS) {
99
+ const value = entry[field]
100
+ if (typeof value === 'string' && value.trim() !== '' && !TERM_ID.test(value.trim())) {
101
+ return value
102
+ }
103
+ }
104
+ return undefined
105
+ }
@@ -5,12 +5,13 @@ import {
5
5
  h,
6
6
  type PageContent,
7
7
  pageHasOwnHeading,
8
+ providedBlockNode,
8
9
  type RenderContext,
9
- renderEntryHeader,
10
10
  resolveBlockForRender,
11
11
  withBlockKey,
12
12
  withBlockVariant,
13
13
  } from '@cogenta/theme-kit'
14
+ import { isArticle, renderArticleFooter, renderArticleHeader, renderPageHeader } from './article.js'
14
15
  import { renderAccordion } from './blocks/accordion.js'
15
16
  import { renderCollectionList } from './blocks/collection-list.js'
16
17
  import { renderCta } from './blocks/cta.js'
@@ -29,23 +30,27 @@ import { renderStatCounter } from './blocks/stat-counter.js'
29
30
  import { renderStats } from './blocks/stats.js'
30
31
  import { renderTestimonial } from './blocks/testimonial.js'
31
32
 
33
+ export type { FetchedEntries, PageContent }
34
+
32
35
  export function renderBlock(
33
36
  block: VocabularyBlock,
34
37
  ctx: RenderContext,
35
38
  entries: FetchedEntries = {},
36
39
  registry?: BlockRegistry,
37
40
  ): HtmlElement | null {
38
- // `block`'s type says `VocabularyBlock`, but the value crossing this
39
- // boundary from stored content is not always literally one of the shared
40
- // vocabulary — resolving here is what turns an unimplemented theme-private
41
- // block into its declared fallback instead of a silently blank slot.
41
+ // A block a plugin provides arrives already rendered by the host, in a
42
+ // process of its own (contract D theme@1.7). Nothing else in this theme
43
+ // needs to know such blocks exist.
44
+ const provided = providedBlockNode(block, ctx)
45
+ if (provided !== null) return withBlockVariant(provided, block.variant)
46
+ // A stored block is not always literally one of the shared vocabulary:
47
+ // resolving here turns an unimplemented theme-private block into its
48
+ // declared fallback instead of a silently blank slot.
42
49
  const resolved = resolveBlockForRender(block, VOCABULARY_NAMES, registry)
43
50
  if (resolved === null) return null
44
51
  const known = resolved as unknown as VocabularyBlock
45
- // `withBlockVariant` (blocks@2.0, RFC 0002) is applied once here, after
46
- // dispatch, rather than inside each of the seventeen block renderers —
47
- // `variant` is envelope data every block carries identically, exactly the
48
- // same reasoning `withBlockKey` in `renderPage` already follows for `_key`.
52
+ // `variant` is envelope data every block carries identically, applied
53
+ // once here rather than by each of the seventeen renderers.
49
54
  return withBlockVariant(renderKnownBlock(known, ctx, entries), known.variant)
50
55
  }
51
56
 
@@ -90,11 +95,9 @@ function renderKnownBlock(
90
95
  case 'logoStrip':
91
96
  return renderLogoStrip(known, ctx)
92
97
  default: {
93
- // Exhaustive over contract B's vocabulary: `known` is `never` here, so
94
- // a block this package does not implement stops it compiling until it
95
- // is. `null`, not a throw — a theme has no `@cogenta/core` error types
96
- // to reach for (contract D refuses that import), and a fallback block
97
- // is the render layer's decision, not the theme's.
98
+ // Exhaustive over contract B: `known` is `never` here, so a block this
99
+ // package does not implement stops it compiling until it is. `null`
100
+ // rather than a throw: choosing a fallback is the render layer's job.
98
101
  const unreachable: never = known
99
102
  void unreachable
100
103
  return null
@@ -102,21 +105,28 @@ function renderKnownBlock(
102
105
  }
103
106
  }
104
107
 
108
+ /** Marks the first `prose` block of an article, the one paragraph a drop cap may open. */
109
+ function withOpening(element: HtmlElement | null): HtmlElement | null {
110
+ if (element === null) return null
111
+ return { ...element, attrs: { ...element.attrs, 'data-opening': 'true' } }
112
+ }
113
+
105
114
  /**
106
- * Wraps every block in `<main id="cg-main">` — the skip link's anchor — and
107
- * marks each rendered block with its own `_key` (`withBlockKey`, mandatory:
108
- * it is what lets the L16 page builder map a clicked element on the real
109
- * rendered page back to the block that produced it). A page whose blocks
110
- * already carry a heading (a `hero`) gets no second `<h1>`.
115
+ * `<main id="cg-main">` is mandatory: it is the skip link's target, written
116
+ * by `@cogenta/cli`'s `theme-render.ts` outside any theme's control.
117
+ *
118
+ * Four openings, and exactly one `h1` in each:
111
119
  *
112
- * A page carrying `entry` (contract D `theme@1.4`) gets `renderEntryHeader`'s
113
- * furniture — classification eyebrow (styled in this masthead's own
114
- * journal-red accent, see `styles/base.css`'s `.cg-entry-header__terms`),
115
- * title, excerpt, date/author/reading-time meta and cover — instead of the
116
- * bare `<h1>` every other page falls back to; `renderEntryHeader` itself
117
- * returns `null` for a `blocks`-only page (e.g. `about`, which carries no
118
- * `entry`) or one that already draws its own heading, so the bare-title
119
- * fallback still applies there, exactly as before.
120
+ * - a page whose blocks include a `hero` lets the hero carry the title;
121
+ * - an entry that carries what an article has (`isArticle`) gets the
122
+ * article header, and its first `prose` block may open on a drop cap;
123
+ * - a page that opens on a listing is a front: the masthead above already
124
+ * names it, so its title stays in the outline for assistive technology and
125
+ * out of sight, and the first story sits directly under the navigation;
126
+ * - anything else gets its title set as a section title.
127
+ *
128
+ * `withBlockKey` stamps every rendered block with its contract-B `_key`, so
129
+ * the visual page builder (L16) can map a clicked element back to its block.
120
130
  */
121
131
  export function renderPage(
122
132
  page: PageContent,
@@ -125,13 +135,31 @@ export function renderPage(
125
135
  registry?: BlockRegistry,
126
136
  ): HtmlElement {
127
137
  const ownHeading = pageHasOwnHeading(page.blocks)
128
- const entryHeader = ownHeading ? null : renderEntryHeader(page, ctx)
138
+ const article = !ownHeading && isArticle(page.entry) ? page.entry : undefined
139
+ const front = !ownHeading && article === undefined && page.blocks[0]?._type === 'collectionList'
140
+ const opening = ownHeading
141
+ ? null
142
+ : article !== undefined
143
+ ? renderArticleHeader(page, article, ctx)
144
+ : front
145
+ ? h('h1', { class: 'cg-visually-hidden' }, page.title)
146
+ : renderPageHeader(page)
147
+
148
+ const firstProse =
149
+ article === undefined ? undefined : page.blocks.find((block) => block._type === 'prose')
150
+
129
151
  return h(
130
152
  'main',
131
- { class: 'cg-main', id: 'cg-main' },
132
- ownHeading ? null : (entryHeader ?? h('h1', { class: 'cg-page__title' }, page.title)),
133
- page.blocks.map((block) =>
134
- withBlockKey(renderBlock(block, ctx, entries, registry), block._key),
135
- ),
153
+ {
154
+ class:
155
+ article !== undefined ? 'cg-main cg-article' : front ? 'cg-main cg-front-page' : 'cg-main',
156
+ id: 'cg-main',
157
+ },
158
+ opening,
159
+ page.blocks.map((block) => {
160
+ const rendered = renderBlock(block, ctx, entries, registry)
161
+ return withBlockKey(block === firstProse ? withOpening(rendered) : rendered, block._key)
162
+ }),
163
+ article === undefined ? null : renderArticleFooter(article),
136
164
  )
137
165
  }