@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.
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/render/blocks/accordion.d.ts +8 -0
- package/dist/src/render/blocks/accordion.d.ts.map +1 -1
- package/dist/src/render/blocks/accordion.js +10 -10
- package/dist/src/render/blocks/accordion.js.map +1 -1
- package/dist/src/render/blocks/collection-list.d.ts +3 -11
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
- package/dist/src/render/blocks/collection-list.js +69 -68
- package/dist/src/render/blocks/collection-list.js.map +1 -1
- package/dist/src/render/blocks/cta.d.ts +6 -0
- package/dist/src/render/blocks/cta.d.ts.map +1 -1
- package/dist/src/render/blocks/cta.js +9 -4
- package/dist/src/render/blocks/cta.js.map +1 -1
- package/dist/src/render/blocks/embed.d.ts.map +1 -1
- package/dist/src/render/blocks/embed.js +14 -13
- package/dist/src/render/blocks/embed.js.map +1 -1
- package/dist/src/render/blocks/faq.d.ts +7 -0
- package/dist/src/render/blocks/faq.d.ts.map +1 -1
- package/dist/src/render/blocks/faq.js +10 -10
- package/dist/src/render/blocks/faq.js.map +1 -1
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
- package/dist/src/render/blocks/feature-grid.js +19 -19
- package/dist/src/render/blocks/feature-grid.js.map +1 -1
- package/dist/src/render/blocks/gallery.d.ts +8 -4
- package/dist/src/render/blocks/gallery.d.ts.map +1 -1
- package/dist/src/render/blocks/gallery.js +19 -13
- package/dist/src/render/blocks/gallery.js.map +1 -1
- package/dist/src/render/blocks/hero.d.ts +12 -9
- package/dist/src/render/blocks/hero.d.ts.map +1 -1
- package/dist/src/render/blocks/hero.js +22 -20
- package/dist/src/render/blocks/hero.js.map +1 -1
- package/dist/src/render/blocks/logo-strip.d.ts +6 -0
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
- package/dist/src/render/blocks/logo-strip.js +6 -10
- package/dist/src/render/blocks/logo-strip.js.map +1 -1
- package/dist/src/render/blocks/logos.d.ts +10 -0
- package/dist/src/render/blocks/logos.d.ts.map +1 -1
- package/dist/src/render/blocks/logos.js +20 -17
- package/dist/src/render/blocks/logos.js.map +1 -1
- package/dist/src/render/blocks/media-figure.d.ts +0 -6
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
- package/dist/src/render/blocks/media-figure.js +28 -16
- package/dist/src/render/blocks/media-figure.js.map +1 -1
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
- package/dist/src/render/blocks/pricing-table.js +16 -20
- package/dist/src/render/blocks/pricing-table.js.map +1 -1
- package/dist/src/render/blocks/prose.d.ts +13 -1
- package/dist/src/render/blocks/prose.d.ts.map +1 -1
- package/dist/src/render/blocks/prose.js +12 -31
- package/dist/src/render/blocks/prose.js.map +1 -1
- package/dist/src/render/blocks/quote.d.ts +5 -3
- package/dist/src/render/blocks/quote.d.ts.map +1 -1
- package/dist/src/render/blocks/quote.js +10 -14
- package/dist/src/render/blocks/quote.js.map +1 -1
- package/dist/src/render/blocks/stat-counter.d.ts +5 -0
- package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
- package/dist/src/render/blocks/stat-counter.js +6 -10
- package/dist/src/render/blocks/stat-counter.js.map +1 -1
- package/dist/src/render/blocks/stats.d.ts +10 -0
- package/dist/src/render/blocks/stats.d.ts.map +1 -1
- package/dist/src/render/blocks/stats.js +13 -8
- package/dist/src/render/blocks/stats.js.map +1 -1
- package/dist/src/render/blocks/testimonial.d.ts +4 -5
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
- package/dist/src/render/blocks/testimonial.js +9 -13
- package/dist/src/render/blocks/testimonial.js.map +1 -1
- package/dist/src/render/chrome.d.ts +36 -1
- package/dist/src/render/chrome.d.ts.map +1 -1
- package/dist/src/render/chrome.js +122 -80
- package/dist/src/render/chrome.js.map +1 -1
- package/dist/src/render/doc-index.d.ts +25 -0
- package/dist/src/render/doc-index.d.ts.map +1 -0
- package/dist/src/render/doc-index.js +49 -0
- package/dist/src/render/doc-index.js.map +1 -0
- package/dist/src/render/doc-page.d.ts +6 -0
- package/dist/src/render/doc-page.d.ts.map +1 -0
- package/dist/src/render/doc-page.js +127 -0
- package/dist/src/render/doc-page.js.map +1 -0
- package/dist/src/render/layout.d.ts +23 -0
- package/dist/src/render/layout.d.ts.map +1 -0
- package/dist/src/render/layout.js +40 -0
- package/dist/src/render/layout.js.map +1 -0
- package/dist/src/render/render-block.d.ts +9 -6
- package/dist/src/render/render-block.d.ts.map +1 -1
- package/dist/src/render/render-block.js +47 -100
- package/dist/src/render/render-block.js.map +1 -1
- package/dist/src/render/rich-text.d.ts +26 -0
- package/dist/src/render/rich-text.d.ts.map +1 -0
- package/dist/src/render/rich-text.js +325 -0
- package/dist/src/render/rich-text.js.map +1 -0
- package/dist/src/render/search.d.ts +15 -0
- package/dist/src/render/search.d.ts.map +1 -0
- package/dist/src/render/search.js +31 -0
- package/dist/src/render/search.js.map +1 -0
- package/dist/src/render/strings.d.ts +51 -0
- package/dist/src/render/strings.d.ts.map +1 -0
- package/dist/src/render/strings.js +60 -0
- package/dist/src/render/strings.js.map +1 -0
- package/dist/src/render/term-archive.d.ts +5 -5
- package/dist/src/render/term-archive.d.ts.map +1 -1
- package/dist/src/render/term-archive.js +27 -16
- package/dist/src/render/term-archive.js.map +1 -1
- package/dist/src/render/widget-areas.d.ts +22 -0
- package/dist/src/render/widget-areas.d.ts.map +1 -0
- package/dist/src/render/widget-areas.js +29 -0
- package/dist/src/render/widget-areas.js.map +1 -0
- package/package.json +4 -4
- package/src/index.ts +2 -0
- package/src/render/blocks/accordion.ts +31 -42
- package/src/render/blocks/collection-list.ts +132 -123
- package/src/render/blocks/cta.ts +23 -18
- package/src/render/blocks/embed.ts +37 -28
- package/src/render/blocks/faq.ts +26 -37
- package/src/render/blocks/feature-grid.ts +34 -42
- package/src/render/blocks/gallery.ts +33 -29
- package/src/render/blocks/hero.ts +41 -45
- package/src/render/blocks/logo-strip.ts +21 -21
- package/src/render/blocks/logos.ts +32 -42
- package/src/render/blocks/media-figure.ts +47 -25
- package/src/render/blocks/pricing-table.ts +34 -41
- package/src/render/blocks/prose.ts +25 -40
- package/src/render/blocks/quote.ts +25 -19
- package/src/render/blocks/stat-counter.ts +27 -29
- package/src/render/blocks/stats.ts +35 -32
- package/src/render/blocks/testimonial.ts +18 -19
- package/src/render/chrome.ts +170 -83
- package/src/render/doc-index.ts +65 -0
- package/src/render/doc-page.ts +303 -0
- package/src/render/layout.ts +73 -0
- package/src/render/render-block.ts +67 -191
- package/src/render/rich-text.ts +446 -0
- package/src/render/search.ts +41 -0
- package/src/render/strings.ts +67 -0
- package/src/render/term-archive.ts +62 -38
- package/src/render/widget-areas.ts +33 -0
- package/src/styles/archive.css +21 -27
- package/src/styles/base.css +438 -838
- package/src/styles/blocks.css +658 -916
- package/src/styles/chrome.css +439 -0
- package/src/styles/docs.css +404 -0
- package/src/styles/rich.css +337 -0
- package/src/styles/theme.css +28 -23
- package/src/styles/tokens.css +209 -215
- package/src/styles/widgets.css +890 -0
- package/tokens.json +15 -15
package/src/render/chrome.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
29
|
-
* a
|
|
30
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
/**
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
-
: `<
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
: `<
|
|
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="
|
|
94
|
-
`<a class="
|
|
95
|
-
|
|
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="
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
: `<
|
|
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="
|
|
118
|
-
`<div class="
|
|
119
|
-
`<
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|