@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
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import type { RichTextDocument, RichTextNode, Span, VocabularyBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
type HtmlElement,
|
|
4
|
+
type HtmlNode,
|
|
5
|
+
h,
|
|
6
|
+
type RenderContext,
|
|
7
|
+
renderRichText,
|
|
8
|
+
text,
|
|
9
|
+
} from '@cogenta/theme-kit'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Running text, as a documentation site needs it.
|
|
13
|
+
*
|
|
14
|
+
* Contract A's rich text (ADR-0013, frozen) is a closed vocabulary: blocks in
|
|
15
|
+
* `normal`/`h2`/`h3`/`h4`/`blockquote` style, list items, media, a rule; the
|
|
16
|
+
* marks `strong`, `em`, `code`, `strikethrough` and links. It has no code
|
|
17
|
+
* block, no note, no table and no key. A documentation theme that could show
|
|
18
|
+
* none of them would not be one, so this module reads four shapes an editor
|
|
19
|
+
* can already write with that vocabulary, and renders them as what they
|
|
20
|
+
* plainly are. Nothing is invented: every word on the page is a word the
|
|
21
|
+
* editor stored, and every other theme renders the same data as ordinary
|
|
22
|
+
* paragraphs and lists. Everything else goes through `@cogenta/theme-kit`'s
|
|
23
|
+
* `renderRichText`, unchanged, so links and nested lists are resolved once
|
|
24
|
+
* for every theme.
|
|
25
|
+
*
|
|
26
|
+
* 1. **Code block.** A paragraph whose spans all carry `code`. When its first
|
|
27
|
+
* span also carries `strong` and more spans follow, that span is the
|
|
28
|
+
* block's label: a file name (`relay.yaml`) or a language (`Terminal`).
|
|
29
|
+
* Lines that start with `#` or `//` are set as comments; in a block that
|
|
30
|
+
* shows shell prompts (`$ `), the prompt is not selectable and the lines
|
|
31
|
+
* without one are set as the program's output.
|
|
32
|
+
* 2. **Note.** A blockquote that opens on a bold `Note`, `Tip`, `Important`,
|
|
33
|
+
* `Warning` or `Caution`. Notes and tips take the teal rule; the rest take
|
|
34
|
+
* an ink rule, since this theme keeps a single accent colour.
|
|
35
|
+
* 3. **Reference table.** A bulleted list, one level deep, where every item
|
|
36
|
+
* opens on a span marked only `code` and goes on: the code is the term
|
|
37
|
+
* (a command, a key, a flag), a run of italic text right after it is the
|
|
38
|
+
* type or default, and the rest is the description. Rendered as a
|
|
39
|
+
* definition list laid out in columns between hairlines.
|
|
40
|
+
* 4. **Key.** An inline `code` span whose whole text is a key or a key chord
|
|
41
|
+
* (`Ctrl+C`, `Enter`) is set as `<kbd>`.
|
|
42
|
+
*
|
|
43
|
+
* Headings (`h2`, `h3`) get an `id` and link to themselves, so a reader can
|
|
44
|
+
* copy the address of a section, and a documentation page can list them in
|
|
45
|
+
* its table of contents.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
type TextBlock = Extract<RichTextNode, { _type: 'block' }>
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Heading anchors
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export interface HeadingAnchor {
|
|
55
|
+
readonly id: string
|
|
56
|
+
readonly text: string
|
|
57
|
+
readonly level: 2 | 3
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Anchors by `${blockKey}:${nodeKey}`, in reading order. */
|
|
61
|
+
export type HeadingAnchors = ReadonlyMap<string, HeadingAnchor>
|
|
62
|
+
|
|
63
|
+
/** Ids the host or the chrome already uses on every page. */
|
|
64
|
+
const RESERVED_IDS: ReadonlySet<string> = new Set([
|
|
65
|
+
'cg-main',
|
|
66
|
+
'cd-search-header',
|
|
67
|
+
'cd-search-menu',
|
|
68
|
+
'cd-search-hero',
|
|
69
|
+
'cd-toc-label',
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
export function slugify(value: string): string {
|
|
73
|
+
const slug = value
|
|
74
|
+
.normalize('NFKD')
|
|
75
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
78
|
+
.replace(/^-+|-+$/g, '')
|
|
79
|
+
return slug === '' ? 'section' : slug
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTextBlock(node: RichTextNode | undefined): node is TextBlock {
|
|
83
|
+
return node !== undefined && node._type === 'block'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function plainText(node: TextBlock): string {
|
|
87
|
+
return node.children.map((span) => span.text).join('')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One id per `h2`/`h3` of every `prose` block on a page, unique across the
|
|
92
|
+
* page: a second "Examples" becomes `examples-2`. Computed once for the whole
|
|
93
|
+
* page, so the table of contents and the headings always agree.
|
|
94
|
+
*/
|
|
95
|
+
export function headingAnchors(blocks: readonly VocabularyBlock[]): HeadingAnchors {
|
|
96
|
+
const used = new Set<string>(RESERVED_IDS)
|
|
97
|
+
const anchors = new Map<string, HeadingAnchor>()
|
|
98
|
+
for (const block of blocks) {
|
|
99
|
+
if (block._type !== 'prose') continue
|
|
100
|
+
for (const node of block.body) {
|
|
101
|
+
if (!isTextBlock(node) || node.listItem !== undefined) continue
|
|
102
|
+
if (node.style !== 'h2' && node.style !== 'h3') continue
|
|
103
|
+
const label = plainText(node).trim()
|
|
104
|
+
if (label === '') continue
|
|
105
|
+
const base = slugify(label)
|
|
106
|
+
let id = base
|
|
107
|
+
for (let suffix = 2; used.has(id); suffix += 1) id = `${base}-${suffix}`
|
|
108
|
+
used.add(id)
|
|
109
|
+
anchors.set(`${block._key}:${node._key}`, {
|
|
110
|
+
id,
|
|
111
|
+
text: label,
|
|
112
|
+
level: node.style === 'h2' ? 2 : 3,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return anchors
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Shape detection
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function hasOnlyMarks(span: Span, allowed: readonly string[]): boolean {
|
|
124
|
+
return span.marks.every((mark) => allowed.includes(mark))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isCodeBlock(node: RichTextNode): boolean {
|
|
128
|
+
return (
|
|
129
|
+
isTextBlock(node) &&
|
|
130
|
+
node.style === 'normal' &&
|
|
131
|
+
node.listItem === undefined &&
|
|
132
|
+
node.children.length > 0 &&
|
|
133
|
+
node.children.every(
|
|
134
|
+
(span) => span.marks.includes('code') && hasOnlyMarks(span, ['code', 'strong']),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const NOTE_LABEL = /^(note|tip|important|warning|caution)\s*[:.]?\s*$/i
|
|
140
|
+
|
|
141
|
+
function calloutLabel(node: RichTextNode): string | null {
|
|
142
|
+
if (!isTextBlock(node) || node.style !== 'blockquote' || node.children.length < 2) return null
|
|
143
|
+
const first = node.children[0]
|
|
144
|
+
if (first === undefined || !first.marks.includes('strong')) return null
|
|
145
|
+
const match = NOTE_LABEL.exec(first.text.trim())
|
|
146
|
+
return match === null ? null : (match[1] as string)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isReferenceItem(node: RichTextNode): boolean {
|
|
150
|
+
if (!isTextBlock(node) || node.listItem !== 'bullet' || (node.level ?? 1) !== 1) return false
|
|
151
|
+
const [first, ...rest] = node.children
|
|
152
|
+
if (first === undefined || rest.length === 0) return false
|
|
153
|
+
if (first.marks.length !== 1 || first.marks[0] !== 'code' || first.text.includes('\n'))
|
|
154
|
+
return false
|
|
155
|
+
return rest.some((span) => span.text.trim() !== '')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Rendering helpers
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/** The inline content of one text block, through theme-kit (marks and links resolved once). */
|
|
163
|
+
function inline(ctx: RenderContext, node: TextBlock, spans: readonly Span[]): readonly HtmlNode[] {
|
|
164
|
+
const { listItem: _listItem, level: _level, ...paragraph } = node
|
|
165
|
+
const [rendered] = renderRichText(ctx, [{ ...paragraph, style: 'normal', children: [...spans] }])
|
|
166
|
+
return rendered !== undefined && rendered.kind === 'element' ? rendered.children : []
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Drops the separator an editor types after a label or a term. */
|
|
170
|
+
function trimLead(spans: readonly Span[]): readonly Span[] {
|
|
171
|
+
const [first, ...rest] = spans
|
|
172
|
+
if (first === undefined) return spans
|
|
173
|
+
return [{ ...first, text: first.text.replace(/^[\s:.\u2013\u2014-]+/u, '') }, ...rest]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function codeLines(source: string): readonly HtmlNode[] {
|
|
177
|
+
const lines = source.replace(/^\n+|\n+$/g, '').split('\n')
|
|
178
|
+
const shell = lines.some((line) => line.startsWith('$ '))
|
|
179
|
+
const nodes: HtmlNode[] = []
|
|
180
|
+
// A command that ends in a backslash goes on to the next line.
|
|
181
|
+
let continued = false
|
|
182
|
+
lines.forEach((line, index) => {
|
|
183
|
+
if (index > 0) nodes.push(text('\n'))
|
|
184
|
+
const trimmed = line.trimStart()
|
|
185
|
+
const wasContinued = continued
|
|
186
|
+
continued = shell && (wasContinued || line.startsWith('$ ')) && line.trimEnd().endsWith('\\')
|
|
187
|
+
if ((trimmed.startsWith('#') && !trimmed.startsWith('#!')) || trimmed.startsWith('//')) {
|
|
188
|
+
nodes.push(h('span', { class: 'cd-code__comment' }, line))
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
if (shell && line.startsWith('$ ')) {
|
|
192
|
+
nodes.push(
|
|
193
|
+
h('span', { class: 'cd-code__prompt', 'aria-hidden': 'true' }, '$ '),
|
|
194
|
+
text(line.slice(2)),
|
|
195
|
+
)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (wasContinued) {
|
|
199
|
+
nodes.push(text(line))
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (shell && line.trim() !== '') {
|
|
203
|
+
nodes.push(h('span', { class: 'cd-code__output' }, line))
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
nodes.push(text(line))
|
|
207
|
+
})
|
|
208
|
+
return nodes
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderCodeBlock(node: TextBlock): HtmlElement {
|
|
212
|
+
const [first, ...rest] = node.children
|
|
213
|
+
const labelled =
|
|
214
|
+
first?.marks.includes('strong') === true && rest.length > 0 && !first.text.includes('\n')
|
|
215
|
+
const label = labelled ? (first as Span).text.trim() : ''
|
|
216
|
+
const source = (labelled ? rest : node.children).map((span) => span.text).join('')
|
|
217
|
+
const pre = h('pre', { class: 'cd-code__pre', tabindex: '0' }, h('code', {}, codeLines(source)))
|
|
218
|
+
if (label === '') return h('div', { class: 'cd-code' }, pre)
|
|
219
|
+
return h(
|
|
220
|
+
'figure',
|
|
221
|
+
{ class: 'cd-code', 'data-labelled': 'true' },
|
|
222
|
+
h('figcaption', { class: 'cd-code__label' }, label),
|
|
223
|
+
pre,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderCallout(ctx: RenderContext, node: TextBlock, label: string): HtmlElement {
|
|
228
|
+
const kind = /^(note|tip)$/i.test(label) ? 'note' : 'warning'
|
|
229
|
+
const normalised = label.charAt(0).toUpperCase() + label.slice(1).toLowerCase()
|
|
230
|
+
return h(
|
|
231
|
+
'div',
|
|
232
|
+
{ class: 'cd-callout', 'data-kind': kind, role: 'note' },
|
|
233
|
+
h('p', { class: 'cd-callout__label' }, normalised),
|
|
234
|
+
h('p', { class: 'cd-callout__text' }, inline(ctx, node, trimLead(node.children.slice(1)))),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderReferenceTable(ctx: RenderContext, items: readonly TextBlock[]): HtmlElement {
|
|
239
|
+
const rows = items.map((node) => {
|
|
240
|
+
const [term, ...rest] = node.children as [Span, ...Span[]]
|
|
241
|
+
const meta: Span[] = []
|
|
242
|
+
let index = 0
|
|
243
|
+
// A run of italic spans (whitespace between them allowed) right after the term.
|
|
244
|
+
while (index < rest.length) {
|
|
245
|
+
const span = rest[index] as Span
|
|
246
|
+
if (span.marks.includes('em'))
|
|
247
|
+
meta.push({ ...span, marks: span.marks.filter((m) => m !== 'em') })
|
|
248
|
+
else if (span.text.trim() !== '' || meta.length === 0) break
|
|
249
|
+
index += 1
|
|
250
|
+
}
|
|
251
|
+
const description = trimLead(rest.slice(index))
|
|
252
|
+
const lastMeta = meta[meta.length - 1]
|
|
253
|
+
if (lastMeta !== undefined) {
|
|
254
|
+
meta[meta.length - 1] = { ...lastMeta, text: lastMeta.text.replace(/[.,;\s]+$/u, '') }
|
|
255
|
+
}
|
|
256
|
+
return { node, term, meta: trimLead(meta), description }
|
|
257
|
+
})
|
|
258
|
+
const hasMeta = rows.some((row) => row.meta.length > 0)
|
|
259
|
+
return h(
|
|
260
|
+
'dl',
|
|
261
|
+
{ class: 'cd-ref', 'data-columns': hasMeta ? '3' : '2' },
|
|
262
|
+
rows.map((row) =>
|
|
263
|
+
h(
|
|
264
|
+
'div',
|
|
265
|
+
{ class: 'cd-ref__row' },
|
|
266
|
+
h('dt', { class: 'cd-ref__term' }, inline(ctx, row.node, [row.term])),
|
|
267
|
+
hasMeta ? h('dd', { class: 'cd-ref__meta' }, inline(ctx, row.node, row.meta)) : null,
|
|
268
|
+
h('dd', { class: 'cd-ref__desc' }, inline(ctx, row.node, row.description)),
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function usesLinks(node: TextBlock): boolean {
|
|
275
|
+
return node.children.some((span) =>
|
|
276
|
+
span.marks.some((mark) => node.markDefs.some((d) => d._key === mark)),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderHeading(ctx: RenderContext, node: TextBlock, anchor: HeadingAnchor): HtmlElement {
|
|
281
|
+
const content = inline(ctx, node, node.children)
|
|
282
|
+
// A heading that already holds a link cannot also be a link to itself.
|
|
283
|
+
if (usesLinks(node)) return h(node.style, { id: anchor.id, class: 'cd-rich__heading' }, content)
|
|
284
|
+
return h(
|
|
285
|
+
node.style,
|
|
286
|
+
{ id: anchor.id, class: 'cd-rich__heading' },
|
|
287
|
+
h('a', { class: 'cd-anchor', href: `#${anchor.id}` }, content),
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Keys
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
const NAMED_KEYS: ReadonlySet<string> = new Set([
|
|
296
|
+
'ctrl',
|
|
297
|
+
'control',
|
|
298
|
+
'cmd',
|
|
299
|
+
'command',
|
|
300
|
+
'alt',
|
|
301
|
+
'option',
|
|
302
|
+
'opt',
|
|
303
|
+
'shift',
|
|
304
|
+
'enter',
|
|
305
|
+
'return',
|
|
306
|
+
'esc',
|
|
307
|
+
'escape',
|
|
308
|
+
'tab',
|
|
309
|
+
'space',
|
|
310
|
+
'backspace',
|
|
311
|
+
'delete',
|
|
312
|
+
'up',
|
|
313
|
+
'down',
|
|
314
|
+
'left',
|
|
315
|
+
'right',
|
|
316
|
+
'pageup',
|
|
317
|
+
'pagedown',
|
|
318
|
+
'⌘',
|
|
319
|
+
'⌥',
|
|
320
|
+
'⇧',
|
|
321
|
+
'⌃',
|
|
322
|
+
])
|
|
323
|
+
const LONE_KEYS: ReadonlySet<string> = new Set([
|
|
324
|
+
'enter',
|
|
325
|
+
'return',
|
|
326
|
+
'esc',
|
|
327
|
+
'escape',
|
|
328
|
+
'tab',
|
|
329
|
+
'backspace',
|
|
330
|
+
])
|
|
331
|
+
const FUNCTION_KEY = /^f([1-9]|1[0-2])$/i
|
|
332
|
+
const CHARACTER_KEY = /^[A-Za-z0-9/.,;=\-[\]]$/
|
|
333
|
+
|
|
334
|
+
export function keyChord(value: string): readonly string[] | null {
|
|
335
|
+
const parts = value.split('+')
|
|
336
|
+
if (parts.some((part) => part.trim() === '')) return null
|
|
337
|
+
const keys = parts.map((part) => part.trim())
|
|
338
|
+
const named = (key: string): boolean =>
|
|
339
|
+
NAMED_KEYS.has(key.toLowerCase()) || FUNCTION_KEY.test(key)
|
|
340
|
+
if (!keys.every((key) => named(key) || CHARACTER_KEY.test(key))) return null
|
|
341
|
+
if (keys.length === 1) {
|
|
342
|
+
const [only] = keys as [string]
|
|
343
|
+
return LONE_KEYS.has(only.toLowerCase()) || FUNCTION_KEY.test(only) ? keys : null
|
|
344
|
+
}
|
|
345
|
+
return keys.some(named) ? keys : null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function withKeys(nodes: readonly HtmlNode[]): HtmlNode[] {
|
|
349
|
+
return nodes.map((node) => {
|
|
350
|
+
if (node.kind !== 'element' || node.tag === 'pre') return node
|
|
351
|
+
const only = node.children.length === 1 ? node.children[0] : undefined
|
|
352
|
+
if (node.tag === 'code' && only !== undefined && only.kind === 'text') {
|
|
353
|
+
const chord = keyChord(only.value)
|
|
354
|
+
if (chord !== null) {
|
|
355
|
+
const parts: HtmlNode[] = []
|
|
356
|
+
chord.forEach((key, index) => {
|
|
357
|
+
if (index > 0) parts.push(text('+'))
|
|
358
|
+
parts.push(h('kbd', {}, key))
|
|
359
|
+
})
|
|
360
|
+
return h('kbd', { class: 'cd-kbd' }, parts)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { ...node, children: withKeys(node.children) }
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// The document
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
export interface DocsRichTextOptions {
|
|
372
|
+
/** The block the document belongs to, and the page's anchors. Absent: headings get no id (a FAQ answer, say). */
|
|
373
|
+
readonly anchors?: { readonly blockKey: string; readonly map: HeadingAnchors }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function renderDocsRichText(
|
|
377
|
+
ctx: RenderContext,
|
|
378
|
+
document: RichTextDocument,
|
|
379
|
+
options: DocsRichTextOptions = {},
|
|
380
|
+
): readonly HtmlNode[] {
|
|
381
|
+
const out: HtmlNode[] = []
|
|
382
|
+
let run: RichTextNode[] = []
|
|
383
|
+
const flush = (): void => {
|
|
384
|
+
if (run.length > 0) out.push(...renderRichText(ctx, run))
|
|
385
|
+
run = []
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let index = 0
|
|
389
|
+
while (index < document.length) {
|
|
390
|
+
const node = document[index] as RichTextNode
|
|
391
|
+
|
|
392
|
+
if (isTextBlock(node) && isCodeBlock(node)) {
|
|
393
|
+
flush()
|
|
394
|
+
out.push(renderCodeBlock(node))
|
|
395
|
+
index += 1
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const label = calloutLabel(node)
|
|
400
|
+
if (label !== null && isTextBlock(node)) {
|
|
401
|
+
flush()
|
|
402
|
+
out.push(renderCallout(ctx, node, label))
|
|
403
|
+
index += 1
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (
|
|
408
|
+
isTextBlock(node) &&
|
|
409
|
+
node.listItem === undefined &&
|
|
410
|
+
(node.style === 'h2' || node.style === 'h3')
|
|
411
|
+
) {
|
|
412
|
+
const anchor = options.anchors?.map.get(`${options.anchors.blockKey}:${node._key}`)
|
|
413
|
+
if (anchor !== undefined) {
|
|
414
|
+
flush()
|
|
415
|
+
out.push(renderHeading(ctx, node, anchor))
|
|
416
|
+
index += 1
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (isTextBlock(node) && node.listItem === 'bullet') {
|
|
422
|
+
// The whole run of consecutive bullet items decides together: a list is
|
|
423
|
+
// a reference table only if every one of its items has the shape.
|
|
424
|
+
let end = index
|
|
425
|
+
while (end < document.length) {
|
|
426
|
+
const candidate = document[end]
|
|
427
|
+
if (!isTextBlock(candidate) || candidate.listItem !== 'bullet') break
|
|
428
|
+
end += 1
|
|
429
|
+
}
|
|
430
|
+
const items = document.slice(index, end)
|
|
431
|
+
if (items.every(isReferenceItem)) {
|
|
432
|
+
flush()
|
|
433
|
+
out.push(renderReferenceTable(ctx, items as TextBlock[]))
|
|
434
|
+
} else {
|
|
435
|
+
run.push(...items)
|
|
436
|
+
}
|
|
437
|
+
index = end
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
run.push(node)
|
|
442
|
+
index += 1
|
|
443
|
+
}
|
|
444
|
+
flush()
|
|
445
|
+
return withKeys(out)
|
|
446
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type HtmlElement, h, renderIcon } from '@cogenta/theme-kit'
|
|
2
|
+
import { word } from './strings.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The search field: a real `GET /search?q=` form, answered by the host's own
|
|
6
|
+
* search page (`cogenta serve`), so it works without a line of client
|
|
7
|
+
* JavaScript. One input, submitted with Enter; the label is visually hidden
|
|
8
|
+
* because the magnifier and the placeholder already say what the field is,
|
|
9
|
+
* and a screen reader still hears the full name.
|
|
10
|
+
*
|
|
11
|
+
* Two sizes: compact in the header, large at the top of the home page. Each
|
|
12
|
+
* copy on a page has its own `id`, so every label points at its own input.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const SEARCH_PATH = '/search'
|
|
16
|
+
|
|
17
|
+
export type SearchSize = 'compact' | 'large'
|
|
18
|
+
|
|
19
|
+
export function renderSearchForm(locale: string, id: string, size: SearchSize): HtmlElement {
|
|
20
|
+
return h(
|
|
21
|
+
'form',
|
|
22
|
+
{
|
|
23
|
+
class: size === 'large' ? 'cd-search cd-search--large' : 'cd-search',
|
|
24
|
+
action: SEARCH_PATH,
|
|
25
|
+
method: 'get',
|
|
26
|
+
role: 'search',
|
|
27
|
+
},
|
|
28
|
+
h('label', { class: 'cg-visually-hidden', for: id }, word(locale, 'searchLabel')),
|
|
29
|
+
renderIcon('search', { className: 'cd-search__icon', size: 20 }),
|
|
30
|
+
h('input', {
|
|
31
|
+
id,
|
|
32
|
+
class: 'cd-search__input',
|
|
33
|
+
type: 'search',
|
|
34
|
+
name: 'q',
|
|
35
|
+
placeholder: word(locale, 'searchPlaceholder'),
|
|
36
|
+
autocomplete: 'off',
|
|
37
|
+
spellcheck: 'false',
|
|
38
|
+
required: true,
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handful of visitor-facing words this theme prints on its own, beyond
|
|
3
|
+
* `THEME_STRINGS` (`@cogenta/theme-kit`): the search field, the navigation
|
|
4
|
+
* disclosures, the table of contents, previous and next, the date a page was
|
|
5
|
+
* last changed. Two languages, like the shared table, and English for
|
|
6
|
+
* anything else.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const STRINGS = {
|
|
10
|
+
en: {
|
|
11
|
+
search: 'Search',
|
|
12
|
+
searchLabel: 'Search the documentation',
|
|
13
|
+
searchPlaceholder: 'Search the documentation',
|
|
14
|
+
menu: 'Menu',
|
|
15
|
+
documentation: 'Documentation',
|
|
16
|
+
docsHome: 'Docs',
|
|
17
|
+
breadcrumb: 'Breadcrumb',
|
|
18
|
+
onThisPage: 'On this page',
|
|
19
|
+
pager: 'Previous and next pages',
|
|
20
|
+
previous: 'Previous',
|
|
21
|
+
next: 'Next',
|
|
22
|
+
lastUpdated: 'Last updated',
|
|
23
|
+
recommended: 'Recommended',
|
|
24
|
+
primary: 'Primary',
|
|
25
|
+
footer: 'Footer',
|
|
26
|
+
},
|
|
27
|
+
fr: {
|
|
28
|
+
search: 'Rechercher',
|
|
29
|
+
searchLabel: 'Rechercher dans la documentation',
|
|
30
|
+
searchPlaceholder: 'Rechercher dans la documentation',
|
|
31
|
+
menu: 'Menu',
|
|
32
|
+
documentation: 'Documentation',
|
|
33
|
+
docsHome: 'Docs',
|
|
34
|
+
breadcrumb: 'Fil d’Ariane',
|
|
35
|
+
onThisPage: 'Sur cette page',
|
|
36
|
+
pager: 'Pages précédente et suivante',
|
|
37
|
+
previous: 'Précédent',
|
|
38
|
+
next: 'Suivant',
|
|
39
|
+
lastUpdated: 'Dernière mise à jour',
|
|
40
|
+
recommended: 'Recommandé',
|
|
41
|
+
primary: 'Principale',
|
|
42
|
+
footer: 'Pied de page',
|
|
43
|
+
},
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
export type ThemeWord = keyof (typeof STRINGS)['en']
|
|
47
|
+
|
|
48
|
+
export function word(locale: string, key: ThemeWord): string {
|
|
49
|
+
const language = locale.toLowerCase().split(/[-_]/)[0]
|
|
50
|
+
return language === 'fr' ? STRINGS.fr[key] : STRINGS.en[key]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A documentation site is usually named "<product> Docs". The header sets the
|
|
55
|
+
* product in ink and the word that says what the site is in the quieter grey,
|
|
56
|
+
* and the copyright line names the product, not the word "Docs".
|
|
57
|
+
*/
|
|
58
|
+
const DOCS_SUFFIX = /^(.+?)\s+(docs|documentation|developer docs|developers|dev docs)$/i
|
|
59
|
+
|
|
60
|
+
export function splitSiteName(name: string): {
|
|
61
|
+
readonly product: string
|
|
62
|
+
readonly suffix: string | null
|
|
63
|
+
} {
|
|
64
|
+
const match = DOCS_SUFFIX.exec(name.trim())
|
|
65
|
+
if (match === null) return { product: name.trim(), suffix: null }
|
|
66
|
+
return { product: match[1] as string, suffix: match[2] as string }
|
|
67
|
+
}
|