@cogenta/theme-docs 0.3.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -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
+ }