@cogenta/theme-docs 0.3.3 → 0.5.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 (148) hide show
  1. package/dist/src/index.d.ts +2 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +2 -0
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/render/blocks/accordion.d.ts +8 -0
  6. package/dist/src/render/blocks/accordion.d.ts.map +1 -1
  7. package/dist/src/render/blocks/accordion.js +10 -10
  8. package/dist/src/render/blocks/accordion.js.map +1 -1
  9. package/dist/src/render/blocks/collection-list.d.ts +3 -11
  10. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  11. package/dist/src/render/blocks/collection-list.js +69 -68
  12. package/dist/src/render/blocks/collection-list.js.map +1 -1
  13. package/dist/src/render/blocks/cta.d.ts +6 -0
  14. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  15. package/dist/src/render/blocks/cta.js +9 -4
  16. package/dist/src/render/blocks/cta.js.map +1 -1
  17. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  18. package/dist/src/render/blocks/embed.js +14 -13
  19. package/dist/src/render/blocks/embed.js.map +1 -1
  20. package/dist/src/render/blocks/faq.d.ts +7 -0
  21. package/dist/src/render/blocks/faq.d.ts.map +1 -1
  22. package/dist/src/render/blocks/faq.js +10 -10
  23. package/dist/src/render/blocks/faq.js.map +1 -1
  24. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  25. package/dist/src/render/blocks/feature-grid.js +19 -19
  26. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  27. package/dist/src/render/blocks/gallery.d.ts +8 -4
  28. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  29. package/dist/src/render/blocks/gallery.js +19 -13
  30. package/dist/src/render/blocks/gallery.js.map +1 -1
  31. package/dist/src/render/blocks/hero.d.ts +12 -9
  32. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  33. package/dist/src/render/blocks/hero.js +22 -20
  34. package/dist/src/render/blocks/hero.js.map +1 -1
  35. package/dist/src/render/blocks/logo-strip.d.ts +6 -0
  36. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
  37. package/dist/src/render/blocks/logo-strip.js +6 -10
  38. package/dist/src/render/blocks/logo-strip.js.map +1 -1
  39. package/dist/src/render/blocks/logos.d.ts +10 -0
  40. package/dist/src/render/blocks/logos.d.ts.map +1 -1
  41. package/dist/src/render/blocks/logos.js +20 -17
  42. package/dist/src/render/blocks/logos.js.map +1 -1
  43. package/dist/src/render/blocks/media-figure.d.ts +0 -6
  44. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  45. package/dist/src/render/blocks/media-figure.js +28 -16
  46. package/dist/src/render/blocks/media-figure.js.map +1 -1
  47. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  48. package/dist/src/render/blocks/pricing-table.js +16 -20
  49. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  50. package/dist/src/render/blocks/prose.d.ts +13 -1
  51. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  52. package/dist/src/render/blocks/prose.js +12 -31
  53. package/dist/src/render/blocks/prose.js.map +1 -1
  54. package/dist/src/render/blocks/quote.d.ts +5 -3
  55. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  56. package/dist/src/render/blocks/quote.js +10 -14
  57. package/dist/src/render/blocks/quote.js.map +1 -1
  58. package/dist/src/render/blocks/stat-counter.d.ts +5 -0
  59. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
  60. package/dist/src/render/blocks/stat-counter.js +6 -10
  61. package/dist/src/render/blocks/stat-counter.js.map +1 -1
  62. package/dist/src/render/blocks/stats.d.ts +10 -0
  63. package/dist/src/render/blocks/stats.d.ts.map +1 -1
  64. package/dist/src/render/blocks/stats.js +13 -8
  65. package/dist/src/render/blocks/stats.js.map +1 -1
  66. package/dist/src/render/blocks/testimonial.d.ts +4 -5
  67. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  68. package/dist/src/render/blocks/testimonial.js +9 -13
  69. package/dist/src/render/blocks/testimonial.js.map +1 -1
  70. package/dist/src/render/chrome.d.ts +36 -1
  71. package/dist/src/render/chrome.d.ts.map +1 -1
  72. package/dist/src/render/chrome.js +122 -80
  73. package/dist/src/render/chrome.js.map +1 -1
  74. package/dist/src/render/doc-index.d.ts +25 -0
  75. package/dist/src/render/doc-index.d.ts.map +1 -0
  76. package/dist/src/render/doc-index.js +49 -0
  77. package/dist/src/render/doc-index.js.map +1 -0
  78. package/dist/src/render/doc-page.d.ts +6 -0
  79. package/dist/src/render/doc-page.d.ts.map +1 -0
  80. package/dist/src/render/doc-page.js +127 -0
  81. package/dist/src/render/doc-page.js.map +1 -0
  82. package/dist/src/render/layout.d.ts +23 -0
  83. package/dist/src/render/layout.d.ts.map +1 -0
  84. package/dist/src/render/layout.js +40 -0
  85. package/dist/src/render/layout.js.map +1 -0
  86. package/dist/src/render/render-block.d.ts +9 -6
  87. package/dist/src/render/render-block.d.ts.map +1 -1
  88. package/dist/src/render/render-block.js +47 -100
  89. package/dist/src/render/render-block.js.map +1 -1
  90. package/dist/src/render/rich-text.d.ts +26 -0
  91. package/dist/src/render/rich-text.d.ts.map +1 -0
  92. package/dist/src/render/rich-text.js +325 -0
  93. package/dist/src/render/rich-text.js.map +1 -0
  94. package/dist/src/render/search.d.ts +15 -0
  95. package/dist/src/render/search.d.ts.map +1 -0
  96. package/dist/src/render/search.js +31 -0
  97. package/dist/src/render/search.js.map +1 -0
  98. package/dist/src/render/strings.d.ts +51 -0
  99. package/dist/src/render/strings.d.ts.map +1 -0
  100. package/dist/src/render/strings.js +60 -0
  101. package/dist/src/render/strings.js.map +1 -0
  102. package/dist/src/render/term-archive.d.ts +5 -5
  103. package/dist/src/render/term-archive.d.ts.map +1 -1
  104. package/dist/src/render/term-archive.js +27 -16
  105. package/dist/src/render/term-archive.js.map +1 -1
  106. package/dist/src/render/widget-areas.d.ts +22 -0
  107. package/dist/src/render/widget-areas.d.ts.map +1 -0
  108. package/dist/src/render/widget-areas.js +29 -0
  109. package/dist/src/render/widget-areas.js.map +1 -0
  110. package/package.json +4 -4
  111. package/src/index.ts +2 -0
  112. package/src/render/blocks/accordion.ts +31 -42
  113. package/src/render/blocks/collection-list.ts +132 -123
  114. package/src/render/blocks/cta.ts +23 -18
  115. package/src/render/blocks/embed.ts +37 -28
  116. package/src/render/blocks/faq.ts +26 -37
  117. package/src/render/blocks/feature-grid.ts +34 -42
  118. package/src/render/blocks/gallery.ts +33 -29
  119. package/src/render/blocks/hero.ts +41 -45
  120. package/src/render/blocks/logo-strip.ts +21 -21
  121. package/src/render/blocks/logos.ts +32 -42
  122. package/src/render/blocks/media-figure.ts +47 -25
  123. package/src/render/blocks/pricing-table.ts +34 -41
  124. package/src/render/blocks/prose.ts +25 -40
  125. package/src/render/blocks/quote.ts +25 -19
  126. package/src/render/blocks/stat-counter.ts +27 -29
  127. package/src/render/blocks/stats.ts +35 -32
  128. package/src/render/blocks/testimonial.ts +18 -19
  129. package/src/render/chrome.ts +170 -83
  130. package/src/render/doc-index.ts +65 -0
  131. package/src/render/doc-page.ts +303 -0
  132. package/src/render/layout.ts +73 -0
  133. package/src/render/render-block.ts +67 -191
  134. package/src/render/rich-text.ts +446 -0
  135. package/src/render/search.ts +41 -0
  136. package/src/render/strings.ts +67 -0
  137. package/src/render/term-archive.ts +62 -38
  138. package/src/render/widget-areas.ts +33 -0
  139. package/src/styles/archive.css +21 -27
  140. package/src/styles/base.css +438 -838
  141. package/src/styles/blocks.css +658 -916
  142. package/src/styles/chrome.css +439 -0
  143. package/src/styles/docs.css +404 -0
  144. package/src/styles/rich.css +337 -0
  145. package/src/styles/theme.css +28 -23
  146. package/src/styles/tokens.css +209 -215
  147. package/src/styles/widgets.css +890 -0
  148. package/tokens.json +15 -15
@@ -5,120 +5,207 @@ import {
5
5
  escapeAttribute,
6
6
  escapeText,
7
7
  renderBrandMark,
8
+ renderFooterWidgets,
9
+ renderIcon,
8
10
  renderSocialLinks,
9
11
  renderThemeToggle,
10
12
  serialize,
11
13
  } from '@cogenta/theme-kit'
14
+ import { renderSearchForm, SEARCH_PATH } from './search.js'
15
+ import { splitSiteName, word } from './strings.js'
12
16
 
13
17
  /**
14
- * This theme's own header and footer — structurally distinct from the
15
- * canonical theme's, not a recolour of it: a docs site's header carries a
16
- * genuine CSS-only mobile menu (a `<details>` disclosure, never a script),
17
- * because "Docs / Guides / Reference / Blog" plus a header action does not
18
- * fit a narrow viewport the way three links do.
18
+ * The header and footer of a documentation site (`theme@1.4`).
19
19
  *
20
- * The mobile panel is a **second, hidden-by-default copy** of the same nav
21
- * markup, not a repositioned single copy: two static layouts (`display:none`
22
- * either way at a given viewport width) is the only zero-JS way to put the
23
- * links inline on a wide screen and inside a disclosure on a narrow one.
24
- * `display:none` removes the inactive copy from the accessibility tree, so a
25
- * screen reader is never offered two "Primary" navigations at once — only
26
- * the sighted, CSS-driven layout differs.
20
+ * Header: one bar held at the top of the window on the page's own ground,
21
+ * under a hairline (no blur, no translucency). The product's name as a text
22
+ * wordmark (or the uploaded logo), a real search field, the top sections of
23
+ * the site, the light/dark control, and the `headerAction` as a quiet
24
+ * outlined control when a site sets one.
27
25
  *
28
- * Footer: brand column (name + tagline), a footer nav column, social links,
29
- * a short "about" note, and Cogenta's own credit (or its white-label
30
- * replacement) — `brandingHtml` placed exactly once, unaltered.
26
+ * Below the wide breakpoint the sections and the search field move into a
27
+ * panel opened by a `<details>` disclosure: no script. The panel is a second,
28
+ * hidden-by-default copy of the same links: at any width exactly one copy is
29
+ * displayed, so a screen reader is never offered two primary navigations. A
30
+ * magnifier link to the search page stays in the bar, so a reader on a phone
31
+ * can search without opening the menu.
32
+ *
33
+ * Footer: columns, the way a developer tool's site is organised. The name,
34
+ * the `tagline`, the `footerNote` (a licence line, a support address) and the
35
+ * social profiles; then the footer navigation, split into columns at each
36
+ * unlinked heading (`submenu-placeholder`) an editor adds in the menu screen.
37
+ * Under a hairline, the copyright year and the product's name, beside the
38
+ * host's `brandingHtml`, placed once, as received.
39
+ *
40
+ * Footer widget columns (`theme@1.6`), when the site placed any, sit between
41
+ * the two, under their own hairline: a row of columns set like the footer
42
+ * navigation's, so a support address or a status link reads as part of the
43
+ * same footer rather than a band added above it.
31
44
  */
32
45
 
33
- function navItems(links: readonly ChromeNavLink[]): string {
34
- return links
35
- .filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
36
- .map((link) => {
37
- const label = escapeText(link.label)
38
- const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
39
- if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
40
- const href = escapeAttribute(link.href)
41
- const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
42
- return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
43
- })
44
- .join('')
46
+ interface FooterGroup {
47
+ readonly heading: string | null
48
+ readonly links: readonly ChromeNavLink[]
49
+ }
50
+
51
+ function usable(links: readonly ChromeNavLink[]): readonly ChromeNavLink[] {
52
+ return links.filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
53
+ }
54
+
55
+ function linkItem(link: ChromeNavLink, className: string): string {
56
+ const label = escapeText(link.label)
57
+ const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
58
+ if (link.href === null) return `<li class="${className}"><span${titleAttr}>${label}</span></li>`
59
+ const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
60
+ return `<li class="${className}"><a href="${escapeAttribute(link.href)}"${target}${titleAttr}>${label}</a></li>`
45
61
  }
46
62
 
47
- function renderNav(links: readonly ChromeNavLink[], className: string, label: string): string {
48
- const items = navItems(links)
49
- return items === ''
50
- ? ''
51
- : `<nav class="${className}" aria-label="${label}"><ul class="cg-menu">${items}</ul></nav>`
63
+ /** The footer menu, split into columns at each unlinked heading. */
64
+ export function footerGroups(links: readonly ChromeNavLink[]): readonly FooterGroup[] {
65
+ const groups: { heading: string | null; links: ChromeNavLink[] }[] = []
66
+ for (const link of usable(links)) {
67
+ if (link.kind === 'submenu-placeholder' && link.href === null) {
68
+ groups.push({ heading: link.label, links: [] })
69
+ continue
70
+ }
71
+ const current = groups[groups.length - 1]
72
+ if (current === undefined) groups.push({ heading: null, links: [link] })
73
+ else current.links.push(link)
74
+ }
75
+ return groups.filter((group) => group.links.length > 0)
52
76
  }
53
77
 
54
- /** The header's own call-to-action link (`theme@1.4`), reusing `.cg-action`'s own look. */
55
- function renderHeaderAction(action: ChromeInput['headerAction'], className: string): string {
56
- if (action === undefined) return ''
57
- return (
58
- `<a class="cg-action ${className}" data-emphasis="primary" ` +
59
- `href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
60
- )
78
+ /** `footerNote` as paragraphs at blank lines, each keeping its own line breaks. */
79
+ function renderFooterNote(note: string): string {
80
+ const paragraphs = note
81
+ .replace(/\r\n?/g, '\n')
82
+ .split(/\n\s*\n/)
83
+ .map((paragraph) =>
84
+ paragraph
85
+ .split('\n')
86
+ .map((line) => line.trim())
87
+ .filter((line) => line !== ''),
88
+ )
89
+ .filter((lines) => lines.length > 0)
90
+ if (paragraphs.length === 0) return ''
91
+ return `<div class="cd-footer__note">${paragraphs
92
+ .map((lines) => `<p>${lines.map((line) => escapeText(line)).join('<br>')}</p>`)
93
+ .join('')}</div>`
61
94
  }
62
95
 
63
- /** Three bars, drawn once for this file — not part of `renderIcon`'s closed vocabulary, since a "menu" glyph names no contract B field. */
64
- const HAMBURGER_SVG =
65
- '<svg class="cg-nav-toggle__icon" viewBox="0 0 24 24" width="22" height="22" fill="none" ' +
66
- 'stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">' +
67
- '<path d="M4 6h16M4 12h16M4 18h16"/></svg>'
96
+ /** "Relay Docs" as the product in ink and the kind of site in grey; any other name as it is. */
97
+ function wordmark(name: string): string {
98
+ const { product, suffix } = splitSiteName(name)
99
+ const productHtml = `<span class="cd-wordmark__product">${escapeText(product)}</span>`
100
+ if (suffix === null) return `<span class="cd-wordmark">${productHtml}</span>`
101
+ return `<span class="cd-wordmark">${productHtml} <span class="cd-wordmark__kind">${escapeText(suffix)}</span></span>`
102
+ }
68
103
 
69
104
  export function renderChrome(input: ChromeInput): ChromeResult {
70
- const siteName = escapeAttribute(input.site.name)
71
- const siteNameText = escapeText(input.site.name)
72
- const mark = renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ?? siteNameText
73
-
74
- const desktopNav = renderNav(input.headerNav, 'cg-site-header__nav', 'Primary')
75
- const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
76
- const desktopAction = renderHeaderAction(input.headerAction, 'cg-site-header__action')
77
-
78
- const mobileNavItems = navItems(input.headerNav)
79
- const mobileAction = renderHeaderAction(input.headerAction, 'cg-nav-toggle__action')
80
- const mobilePanel =
81
- mobileNavItems === '' && mobileAction === ''
105
+ const locale = input.locale
106
+ const siteName = escapeText(input.site.name)
107
+ const home = escapeAttribute(input.homeHref)
108
+ const headerLinks = usable(input.headerNav)
109
+ .map((link) => linkItem(link, 'cd-header__item'))
110
+ .join('')
111
+
112
+ const logo = renderBrandMark(input.brand, { className: 'cd-header__logo' })
113
+ const mark = logo ?? wordmark(input.site.name)
114
+ const toggle = serialize(renderThemeToggle(locale, { className: 'cg-theme-toggle' }))
115
+ const action =
116
+ input.headerAction === undefined
82
117
  ? ''
83
- : `<nav class="cg-nav-toggle__panel" aria-label="Primary">` +
84
- `${mobileNavItems === '' ? '' : `<ul class="cg-menu">${mobileNavItems}</ul>`}` +
85
- `${mobileAction}</nav>`
118
+ : `<a class="cg-action cd-header__action" data-emphasis="outline" href="${escapeAttribute(
119
+ input.headerAction.href,
120
+ )}">${escapeText(input.headerAction.label)}</a>`
86
121
 
87
- const toggle =
88
- mobilePanel === ''
122
+ const nav =
123
+ headerLinks === ''
124
+ ? ''
125
+ : `<nav class="cd-header__nav" aria-label="${escapeAttribute(word(locale, 'primary'))}">` +
126
+ `<ul class="cd-header__links">${headerLinks}</ul></nav>`
127
+
128
+ const searchIcon = renderIcon('search', { className: 'cd-header__search-icon', size: 20 })
129
+ const searchLink =
130
+ `<a class="cd-header__search-link" href="${SEARCH_PATH}" ` +
131
+ `aria-label="${escapeAttribute(word(locale, 'search'))}">` +
132
+ `${searchIcon === null ? '' : serialize(searchIcon)}</a>`
133
+
134
+ const panelLinks =
135
+ headerLinks === ''
136
+ ? ''
137
+ : `<nav class="cd-menu__nav" aria-label="${escapeAttribute(word(locale, 'primary'))}">` +
138
+ `<ul class="cd-menu__links">${usable(input.headerNav)
139
+ .map((link) => linkItem(link, 'cd-menu__item'))
140
+ .join('')}</ul></nav>`
141
+ const panelAction =
142
+ input.headerAction === undefined
89
143
  ? ''
90
- : `<details class="cg-nav-toggle"><summary class="cg-nav-toggle__button" aria-label="Menu">${HAMBURGER_SVG}</summary>${mobilePanel}</details>`
144
+ : `<a class="cg-action cd-menu__action" data-emphasis="outline" href="${escapeAttribute(
145
+ input.headerAction.href,
146
+ )}">${escapeText(input.headerAction.label)}</a>`
147
+ const menu =
148
+ `<details class="cd-menu">` +
149
+ `<summary class="cd-menu__button" aria-label="${escapeAttribute(word(locale, 'menu'))}">` +
150
+ `<span class="cd-menu__bar"></span><span class="cd-menu__bar"></span></summary>` +
151
+ `<div class="cd-menu__panel">` +
152
+ `${serialize(renderSearchForm(locale, 'cd-search-menu', 'compact'))}${panelLinks}${panelAction}` +
153
+ `</div></details>`
91
154
 
92
155
  const header =
93
- `<header class="cg-site-header"><div class="cg-site-header__inner">` +
94
- `<a class="cg-site-header__home" href="${escapeAttribute(input.homeHref)}">${mark}</a>` +
95
- `${desktopNav}${themeToggle}${desktopAction}${toggle}` +
156
+ `<header class="cd-header"><div class="cd-header__inner">` +
157
+ `<a class="cd-header__brand" href="${home}">${mark}</a>` +
158
+ `<div class="cd-header__search">${serialize(renderSearchForm(locale, 'cd-search-header', 'compact'))}</div>` +
159
+ `${nav}` +
160
+ `<div class="cd-header__end">${searchLink}${action}${toggle}${menu}</div>` +
96
161
  `</div></header>`
97
162
 
98
- const footerNav = renderNav(input.footerNav, 'cg-site-footer__nav', 'Footer')
99
163
  const tagline =
100
164
  input.tagline === undefined
101
165
  ? ''
102
- : `<p class="cg-site-footer__tagline">${escapeText(input.tagline)}</p>`
103
- const socialElement =
104
- input.social === undefined
105
- ? null
106
- : renderSocialLinks(input.social, {
107
- className: 'cg-site-footer__social',
108
- itemClassName: 'cg-site-footer__social-item',
109
- })
110
- const social = socialElement === null ? '' : serialize(socialElement)
111
- const footerNote =
112
- input.footerNote === undefined
166
+ : `<p class="cd-footer__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
167
+ const note = input.footerNote === undefined ? '' : renderFooterNote(input.footerNote)
168
+ const social = renderSocialLinks(input.social, {
169
+ className: 'cd-footer__social',
170
+ itemClassName: 'cd-footer__social-item',
171
+ })
172
+ const groups = footerGroups(input.footerNav)
173
+ const footerNav =
174
+ groups.length === 0
113
175
  ? ''
114
- : `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
176
+ : `<nav class="cd-footer__nav" aria-label="${escapeAttribute(word(locale, 'footer'))}" data-columns="${Math.min(groups.length, 4)}">${groups
177
+ .map(
178
+ (group) =>
179
+ `<div class="cd-footer__group">${
180
+ group.heading === null
181
+ ? ''
182
+ : `<p class="cd-footer__heading">${escapeText(group.heading)}</p>`
183
+ }<ul class="cd-footer__links">${group.links
184
+ .map((link) => linkItem(link, 'cd-footer__item'))
185
+ .join('')}</ul></div>`,
186
+ )
187
+ .join('')}</nav>`
188
+ const year = new Date().getFullYear()
189
+ const owner = escapeText(splitSiteName(input.site.name).product)
190
+ const footerWidgets = renderFooterWidgets(input.widgets, {
191
+ className: 'cd-footer__widgets',
192
+ headingLevel: 'h2',
193
+ })
115
194
 
116
195
  const footer =
117
- `<footer class="cg-site-footer"><div class="cg-site-footer__grid">` +
118
- `<div class="cg-site-footer__brand"><a href="${escapeAttribute(input.homeHref)}">${siteName}</a>${tagline}${social}</div>` +
119
- `<div class="cg-site-footer__nav-col">${footerNav}</div>` +
120
- `<div class="cg-site-footer__about-col">${footerNote}<div class="cg-site-footer__branding">${input.brandingHtml}</div></div>` +
121
- `</div><div class="cg-site-footer__bottom"><span>${siteName}</span></div></footer>`
196
+ `<footer class="cd-footer"><div class="cd-footer__inner">` +
197
+ `<div class="cd-footer__about">` +
198
+ `<a class="cd-footer__name" href="${home}">${siteName}</a>` +
199
+ `${tagline}${note}` +
200
+ `${social === null ? '' : serialize(social)}` +
201
+ `</div>` +
202
+ `${footerNav}` +
203
+ `${footerWidgets === null ? '' : serialize(footerWidgets)}` +
204
+ `<div class="cd-footer__legal">` +
205
+ `<p class="cd-footer__copyright">© ${year} ${owner}</p>` +
206
+ `${input.brandingHtml === '' ? '' : `<div class="cd-footer__branding">${input.brandingHtml}</div>`}` +
207
+ `</div>` +
208
+ `</div></footer>`
122
209
 
123
210
  return { header, footer }
124
211
  }
@@ -0,0 +1,65 @@
1
+ import type { CollectionListBlock, VocabularyBlock } from '@cogenta/blocks'
2
+ import type { ContentEntry, RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * The documentation's own order, shared by the sidebar, the home page index
6
+ * and previous/next.
7
+ *
8
+ * A doc page carries two plain fields: `section` (the group it belongs to)
9
+ * and `order` (its place in that group). Neither is a sort field contract B
10
+ * allows a `collectionList` to ask for (`id`/`createdAt`/`updatedAt` only),
11
+ * so the list is fetched by creation date and ordered here: pages within a
12
+ * section by `order`, then by the order they were fetched in; sections by the
13
+ * smallest `order` they hold, then by the order they first appear. Two pages
14
+ * created in the same millisecond can come back in either order, so the
15
+ * documentation never depends on creation time alone.
16
+ */
17
+
18
+ export const DOC_PAGE_COLLECTION = 'doc_page'
19
+
20
+ export interface DocGroup {
21
+ readonly section: string
22
+ readonly entries: readonly ContentEntry[]
23
+ }
24
+
25
+ export function isDocIndexBlock(block: VocabularyBlock): block is CollectionListBlock {
26
+ return block._type === 'collectionList' && block.collection === DOC_PAGE_COLLECTION
27
+ }
28
+
29
+ export function groupDocPages(
30
+ entries: readonly ContentEntry[],
31
+ ctx: RenderContext,
32
+ ): readonly DocGroup[] {
33
+ const groups = new Map<string, { entry: ContentEntry; position: number }[]>()
34
+ entries.forEach((entry, position) => {
35
+ const section =
36
+ typeof entry.section === 'string' && entry.section.trim() !== ''
37
+ ? entry.section.trim()
38
+ : ctx.t('entry.untitled')
39
+ const list = groups.get(section) ?? []
40
+ list.push({ entry, position })
41
+ groups.set(section, list)
42
+ })
43
+ const orderOf = (entry: ContentEntry): number =>
44
+ typeof entry.order === 'number' ? entry.order : Number.POSITIVE_INFINITY
45
+ return [...groups.entries()]
46
+ .map(([section, list], appearance) => ({
47
+ section,
48
+ appearance,
49
+ first: Math.min(...list.map((item) => orderOf(item.entry))),
50
+ entries: [...list]
51
+ .sort((a, b) => {
52
+ const orderA = orderOf(a.entry)
53
+ const orderB = orderOf(b.entry)
54
+ return orderA === orderB ? a.position - b.position : orderA - orderB
55
+ })
56
+ .map((item) => item.entry),
57
+ }))
58
+ .sort((a, b) => (a.first === b.first ? a.appearance - b.appearance : a.first - b.first))
59
+ .map(({ section, entries }) => ({ section, entries }))
60
+ }
61
+
62
+ /** The reading order of the whole documentation: every group, in turn. */
63
+ export function readingOrder(groups: readonly DocGroup[]): readonly ContentEntry[] {
64
+ return groups.flatMap((group) => group.entries)
65
+ }
@@ -0,0 +1,303 @@
1
+ import type { BlockRegistry, CollectionListBlock, VocabularyBlock } from '@cogenta/blocks'
2
+ import {
3
+ type ContentEntry,
4
+ entryHref,
5
+ entryTitle,
6
+ type FetchedEntries,
7
+ type HtmlElement,
8
+ h,
9
+ type PageContent,
10
+ pageHasOwnHeading,
11
+ type RenderContext,
12
+ withBlockKey,
13
+ } from '@cogenta/theme-kit'
14
+ import { type DocGroup, groupDocPages, readingOrder } from './doc-index.js'
15
+ import { longDate } from './layout.js'
16
+ import { type HeadingAnchor, type HeadingAnchors, headingAnchors } from './rich-text.js'
17
+ import { word } from './strings.js'
18
+
19
+ /**
20
+ * A documentation page: navigation, the reading column, the table of
21
+ * contents.
22
+ *
23
+ * The navigation comes from the page's own first block, a `collectionList` on
24
+ * `doc_page` that the documentation blueprint seeds on every page for this
25
+ * purpose (contract B has no "this block is chrome" flag, and inventing one
26
+ * would be a contract change for one theme's layout). It is rendered as the
27
+ * sidebar and dropped from the stream of content blocks.
28
+ *
29
+ * Three columns on a wide screen: the sidebar held in view as the page
30
+ * scrolls, the article on its measure, and "On this page" built from the
31
+ * article's own `h2`/`h3`. The table of contents has no scroll spy (that
32
+ * would need a script); it is a plain list of links that stays in view. Two
33
+ * columns on a laptop (the table of contents goes), one on a phone, where the
34
+ * navigation folds into a `<details>` disclosure at the top of the article.
35
+ *
36
+ * The sidebar is in two copies: a plain `<nav>` for wide screens, and the
37
+ * same links inside the disclosure for narrow ones. A closed `<details>`
38
+ * hides its content through the browser's own `::details-content` box, which
39
+ * a stylesheet cannot reliably reopen on a wide screen (verified in Chrome in
40
+ * L25), so the desktop column is never a disclosure. Exactly one copy is
41
+ * displayed at any width, so a screen reader is never offered two.
42
+ *
43
+ * Under the article: the date the page last changed, and links to the
44
+ * previous and next pages in the documentation's reading order. No comment
45
+ * form and no "was this helpful" widget: neither could be answered honestly
46
+ * by a static page.
47
+ */
48
+
49
+ function sidebarLinks(
50
+ groups: readonly DocGroup[],
51
+ ctx: RenderContext,
52
+ current: string,
53
+ ): readonly HtmlElement[] {
54
+ return groups.map((group) =>
55
+ h(
56
+ 'div',
57
+ { class: 'cd-sidenav__group' },
58
+ h('p', { class: 'cd-sidenav__heading' }, group.section),
59
+ h(
60
+ 'ul',
61
+ { class: 'cd-sidenav__links' },
62
+ group.entries.map((entry) => {
63
+ const target = entryHref(entry, ctx)
64
+ return h(
65
+ 'li',
66
+ { class: 'cd-sidenav__item' },
67
+ h(
68
+ 'a',
69
+ {
70
+ class: 'cd-sidenav__link',
71
+ href: target,
72
+ 'aria-current': target === current ? 'page' : undefined,
73
+ },
74
+ entryTitle(entry, ctx),
75
+ ),
76
+ )
77
+ }),
78
+ ),
79
+ ),
80
+ )
81
+ }
82
+
83
+ function renderSidebar(
84
+ groups: readonly DocGroup[],
85
+ ctx: RenderContext,
86
+ current: string,
87
+ currentEntry: ContentEntry | undefined,
88
+ title: string,
89
+ ): readonly HtmlElement[] {
90
+ const label = word(ctx.locale, 'documentation')
91
+ const section = typeof currentEntry?.section === 'string' ? currentEntry.section : undefined
92
+ return [
93
+ h(
94
+ 'nav',
95
+ { class: 'cd-sidenav cd-sidenav--wide', 'aria-label': label },
96
+ sidebarLinks(groups, ctx, current),
97
+ ),
98
+ h(
99
+ 'details',
100
+ { class: 'cd-sidenav-disclosure' },
101
+ h(
102
+ 'summary',
103
+ { class: 'cd-sidenav-disclosure__summary' },
104
+ h('span', { class: 'cd-sidenav-disclosure__label' }, word(ctx.locale, 'menu')),
105
+ h(
106
+ 'span',
107
+ { class: 'cd-sidenav-disclosure__where' },
108
+ section === undefined ? title : `${section} / ${title}`,
109
+ ),
110
+ ),
111
+ h(
112
+ 'nav',
113
+ { class: 'cd-sidenav cd-sidenav--narrow', 'aria-label': label },
114
+ sidebarLinks(groups, ctx, current),
115
+ ),
116
+ ),
117
+ ]
118
+ }
119
+
120
+ function renderBreadcrumb(
121
+ ctx: RenderContext,
122
+ section: string | undefined,
123
+ title: string,
124
+ ): HtmlElement {
125
+ return h(
126
+ 'nav',
127
+ { class: 'cd-breadcrumb', 'aria-label': word(ctx.locale, 'breadcrumb') },
128
+ h(
129
+ 'ol',
130
+ { class: 'cd-breadcrumb__items' },
131
+ h('li', {}, h('a', { href: ctx.link('/') }, word(ctx.locale, 'docsHome'))),
132
+ section === undefined ? null : h('li', {}, section),
133
+ h('li', { 'aria-current': 'page' }, title),
134
+ ),
135
+ )
136
+ }
137
+
138
+ function renderToc(ctx: RenderContext, anchors: HeadingAnchors): HtmlElement | null {
139
+ const list = [...anchors.values()]
140
+ if (list.length < 2) return null
141
+ const items: HtmlElement[] = []
142
+ let children: HeadingAnchor[] = []
143
+ let parent: HeadingAnchor | undefined
144
+ const close = (): void => {
145
+ if (parent === undefined) {
146
+ for (const orphan of children) items.push(tocItem(orphan, []))
147
+ } else {
148
+ items.push(tocItem(parent, children))
149
+ }
150
+ children = []
151
+ parent = undefined
152
+ }
153
+ for (const anchor of list) {
154
+ if (anchor.level === 2) {
155
+ close()
156
+ parent = anchor
157
+ } else {
158
+ children.push(anchor)
159
+ }
160
+ }
161
+ close()
162
+ return h(
163
+ 'nav',
164
+ { class: 'cd-toc', 'aria-labelledby': 'cd-toc-label' },
165
+ h('p', { class: 'cd-toc__label', id: 'cd-toc-label' }, word(ctx.locale, 'onThisPage')),
166
+ h('ol', { class: 'cd-toc__items' }, items),
167
+ )
168
+ }
169
+
170
+ function tocItem(anchor: HeadingAnchor, children: readonly HeadingAnchor[]): HtmlElement {
171
+ return h(
172
+ 'li',
173
+ { class: 'cd-toc__item', 'data-level': String(anchor.level) },
174
+ h('a', { class: 'cd-toc__link', href: `#${anchor.id}` }, anchor.text),
175
+ children.length === 0
176
+ ? null
177
+ : h(
178
+ 'ol',
179
+ { class: 'cd-toc__items' },
180
+ children.map((child) => tocItem(child, [])),
181
+ ),
182
+ )
183
+ }
184
+
185
+ function pagerLink(
186
+ ctx: RenderContext,
187
+ entry: ContentEntry | undefined,
188
+ direction: 'previous' | 'next',
189
+ ): HtmlElement | null {
190
+ if (entry === undefined) return null
191
+ return h(
192
+ 'a',
193
+ {
194
+ class: 'cd-pager__link',
195
+ 'data-direction': direction,
196
+ href: entryHref(entry, ctx),
197
+ rel: direction === 'previous' ? 'prev' : 'next',
198
+ },
199
+ h('span', { class: 'cd-pager__label' }, word(ctx.locale, direction)),
200
+ h('span', { class: 'cd-pager__title' }, entryTitle(entry, ctx)),
201
+ )
202
+ }
203
+
204
+ function renderFoot(
205
+ page: PageContent,
206
+ ctx: RenderContext,
207
+ order: readonly ContentEntry[],
208
+ currentIndex: number,
209
+ ): HtmlElement {
210
+ const updated = page.entry?.updatedAt
211
+ const previous = currentIndex > 0 ? order[currentIndex - 1] : undefined
212
+ const next = currentIndex >= 0 ? order[currentIndex + 1] : undefined
213
+ const pager =
214
+ previous === undefined && next === undefined
215
+ ? null
216
+ : h(
217
+ 'nav',
218
+ { class: 'cd-pager', 'aria-label': word(ctx.locale, 'pager') },
219
+ pagerLink(ctx, previous, 'previous'),
220
+ pagerLink(ctx, next, 'next'),
221
+ )
222
+ return h(
223
+ 'footer',
224
+ { class: 'cd-doc__foot' },
225
+ updated === undefined
226
+ ? null
227
+ : h(
228
+ 'p',
229
+ { class: 'cd-doc__updated' },
230
+ `${word(ctx.locale, 'lastUpdated')} `,
231
+ h('time', { datetime: updated }, longDate(updated, ctx.locale)),
232
+ ),
233
+ pager,
234
+ )
235
+ }
236
+
237
+ export type BlockRenderer = (
238
+ block: VocabularyBlock,
239
+ ctx: RenderContext,
240
+ entries: FetchedEntries,
241
+ registry: BlockRegistry | undefined,
242
+ anchors: HeadingAnchors,
243
+ ) => HtmlElement | null
244
+
245
+ export function renderDocPage(
246
+ page: PageContent,
247
+ ctx: RenderContext,
248
+ sidebarBlock: CollectionListBlock,
249
+ entries: FetchedEntries,
250
+ registry: BlockRegistry | undefined,
251
+ renderOne: BlockRenderer,
252
+ ): HtmlElement {
253
+ const groups = groupDocPages(entries[sidebarBlock._key] ?? [], ctx)
254
+ const order = readingOrder(groups)
255
+ const current = ctx.url.pathname
256
+ const currentIndex = order.findIndex((entry) => entryHref(entry, ctx) === current)
257
+ const currentEntry = currentIndex >= 0 ? order[currentIndex] : undefined
258
+ const section = typeof currentEntry?.section === 'string' ? currentEntry.section : undefined
259
+
260
+ const content = page.blocks.slice(1)
261
+ const anchors = headingAnchors(content)
262
+ const ownHeading = pageHasOwnHeading(content)
263
+ const toc = renderToc(ctx, anchors)
264
+
265
+ return h(
266
+ 'main',
267
+ { class: 'cg-main cd-doc', id: 'cg-main', 'data-toc': toc === null ? 'false' : 'true' },
268
+ h(
269
+ 'div',
270
+ { class: 'cd-doc__layout' },
271
+ withBlockKey(
272
+ h(
273
+ 'div',
274
+ { class: 'cd-doc__sidebar' },
275
+ renderSidebar(groups, ctx, current, currentEntry, page.title),
276
+ ),
277
+ sidebarBlock._key,
278
+ ),
279
+ h(
280
+ 'article',
281
+ { class: 'cd-doc__article' },
282
+ h(
283
+ 'header',
284
+ { class: 'cd-doc__head' },
285
+ renderBreadcrumb(ctx, section, page.title),
286
+ ownHeading ? null : h('h1', { class: 'cd-doc__title' }, page.title),
287
+ page.entry?.excerpt === undefined
288
+ ? null
289
+ : h('p', { class: 'cd-doc__lede' }, page.entry.excerpt),
290
+ ),
291
+ h(
292
+ 'div',
293
+ { class: 'cd-doc__body' },
294
+ content.map((block) =>
295
+ withBlockKey(renderOne(block, ctx, entries, registry, anchors), block._key),
296
+ ),
297
+ ),
298
+ renderFoot(page, ctx, order, currentIndex),
299
+ ),
300
+ toc === null ? null : h('div', { class: 'cd-doc__toc' }, toc),
301
+ ),
302
+ )
303
+ }