@casoon/pages-theme 0.0.0-stage → 0.12.6

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/assets/casoon-logo-dark.svg +70 -0
  4. package/assets/casoon-logo.svg +71 -0
  5. package/assets/favicon.svg +1 -0
  6. package/components/AnchorGrid.astro +135 -0
  7. package/components/ApiEntry.astro +136 -0
  8. package/components/Badge.astro +31 -0
  9. package/components/Badges.astro +10 -0
  10. package/components/BuildOutput.astro +84 -0
  11. package/components/Button.astro +55 -0
  12. package/components/CallToAction.astro +64 -0
  13. package/components/Callout.astro +66 -0
  14. package/components/CodeBlock.astro +84 -0
  15. package/components/Demo.astro +80 -0
  16. package/components/ExamplePanel.astro +153 -0
  17. package/components/Facts.astro +112 -0
  18. package/components/Faq.astro +123 -0
  19. package/components/Features.astro +139 -0
  20. package/components/FileSet.astro +258 -0
  21. package/components/Footer.astro +160 -0
  22. package/components/Header.astro +278 -0
  23. package/components/Hero.astro +318 -0
  24. package/components/Install.astro +154 -0
  25. package/components/Integrations.astro +125 -0
  26. package/components/Keymap.astro +102 -0
  27. package/components/Quickstart.astro +95 -0
  28. package/components/ReleaseHighlights.astro +115 -0
  29. package/components/Spotlight.astro +177 -0
  30. package/components/Swatch.astro +45 -0
  31. package/components/Tabs.astro +90 -0
  32. package/components/Terminal.astro +79 -0
  33. package/components/index.ts +25 -0
  34. package/content.ts +23 -0
  35. package/fonts/OFL.txt +95 -0
  36. package/fonts/inter-latin-400-normal.woff2 +0 -0
  37. package/fonts/inter-latin-500-normal.woff2 +0 -0
  38. package/fonts/inter-latin-600-normal.woff2 +0 -0
  39. package/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  40. package/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  41. package/fonts/onest-latin-wght-normal.woff2 +0 -0
  42. package/index.ts +204 -0
  43. package/layouts/Base.astro +129 -0
  44. package/layouts/Docs.astro +286 -0
  45. package/lib/ansi.ts +168 -0
  46. package/lib/changelog.ts +85 -0
  47. package/lib/docs.ts +54 -0
  48. package/lib/i18n.ts +136 -0
  49. package/lib/og.ts +91 -0
  50. package/lib/showcase.ts +48 -0
  51. package/lib/url.ts +16 -0
  52. package/lib/version.ts +50 -0
  53. package/package.json +56 -4
  54. package/routes/404.astro +49 -0
  55. package/routes/changelog.astro +129 -0
  56. package/routes/docs/[...slug].astro +39 -0
  57. package/routes/showcase/[slug].astro +108 -0
  58. package/routes/showcase/index.astro +195 -0
  59. package/styles/base.css +307 -0
  60. package/styles/fonts.css +52 -0
  61. package/styles/tokens.css +136 -0
  62. package/virtual.d.ts +12 -0
@@ -0,0 +1,286 @@
1
+ ---
2
+ import config from 'virtual:casoon-pages/config';
3
+ import type { MarkdownHeading } from 'astro';
4
+ import type { DocsNavGroup } from '../lib/docs';
5
+ import { t } from '../lib/i18n';
6
+ import Base from './Base.astro';
7
+
8
+ interface Props {
9
+ id: string;
10
+ title: string;
11
+ description?: string;
12
+ /** Path of the source file relative to the repository root, for the edit link. */
13
+ sourcePath: string;
14
+ groups: DocsNavGroup[];
15
+ headings: MarkdownHeading[];
16
+ }
17
+
18
+ const { id, title, description, sourcePath, groups, headings } = Astro.props;
19
+ const flat = groups.flatMap((g) => g.items);
20
+ const index = flat.findIndex((item) => item.id === id);
21
+ const prev = index > 0 ? flat[index - 1] : undefined;
22
+ const next = index >= 0 && index < flat.length - 1 ? flat[index + 1] : undefined;
23
+ const group = groups.find((g) => g.items.some((item) => item.id === id));
24
+ const toc = headings.filter((h) => h.depth === 2);
25
+ const s = t(config.lang);
26
+ const editUrl = config.repo
27
+ ? `https://github.com/${config.repo}/edit/${config.branch}/${sourcePath}`
28
+ : '';
29
+ ---
30
+
31
+ <Base title={title} description={description}>
32
+ <div class="docs">
33
+ <aside class="sidebar">
34
+ <nav aria-label={s.docsNav}>
35
+ {
36
+ groups.map((g) => (
37
+ <div class="group">
38
+ <p class="group-title">{g.label}</p>
39
+ <ul>
40
+ {g.items.map((item) => (
41
+ <li>
42
+ <a href={item.href} aria-current={item.id === id ? 'page' : undefined}>
43
+ {item.label}
44
+ </a>
45
+ </li>
46
+ ))}
47
+ </ul>
48
+ </div>
49
+ ))
50
+ }
51
+ </nav>
52
+ </aside>
53
+
54
+ <article class="content">
55
+ <nav class="crumb" aria-label={s.breadcrumb}>
56
+ <ol>
57
+ <li>{s.docs}</li>
58
+ {group && <li>{group.label}</li>}
59
+ <li aria-current="page">{title}</li>
60
+ </ol>
61
+ </nav>
62
+ <h1>{title}</h1>
63
+ {description && <p class="lead">{description}</p>}
64
+ <div class="cp-prose"><slot /></div>
65
+
66
+ {
67
+ (prev || next) && (
68
+ <nav class="pager" aria-label={s.pagerDocs}>
69
+ {prev ? (
70
+ <a href={prev.href}>
71
+ <span class="dir">{s.previous}</span>
72
+ <span class="label">{prev.label}</span>
73
+ </a>
74
+ ) : (
75
+ <span />
76
+ )}
77
+ {next && (
78
+ <a class="next" href={next.href}>
79
+ <span class="dir">{s.next}</span>
80
+ <span class="label">{next.label}</span>
81
+ </a>
82
+ )}
83
+ </nav>
84
+ )
85
+ }
86
+ <p class="meta">
87
+ {editUrl && <a href={editUrl}>{s.editPage}</a>}
88
+ {config.version && <span>{editUrl && ' · '}{s.docsFor(config.version)}</span>}
89
+ </p>
90
+ </article>
91
+
92
+ {
93
+ toc.length > 0 && (
94
+ <aside class="toc">
95
+ <nav aria-labelledby="toc-title">
96
+ <p class="group-title" id="toc-title">
97
+ {s.onThisPage}
98
+ </p>
99
+ <ul>
100
+ {toc.map((h) => (
101
+ <li>
102
+ <a href={`#${h.slug}`}>{h.text}</a>
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ </nav>
107
+ </aside>
108
+ )
109
+ }
110
+ </div>
111
+ </Base>
112
+
113
+ <style>
114
+ .docs {
115
+ display: flex;
116
+ align-items: flex-start;
117
+ gap: 48px;
118
+ max-width: 1320px;
119
+ margin: 0 auto;
120
+ padding: 0 24px;
121
+ }
122
+ /* The sidebar scrolls with the page: as a sticky box with its own scroll area, a long menu
123
+ was cut off at the footer on short pages. The table of contents stays in view. */
124
+ .toc {
125
+ position: sticky;
126
+ top: var(--header-h);
127
+ max-height: calc(100vh - var(--header-h));
128
+ overflow: auto;
129
+ }
130
+ .sidebar {
131
+ flex: 0 0 220px;
132
+ padding: 40px 0 60px;
133
+ }
134
+ .toc {
135
+ flex: 0 0 180px;
136
+ padding: 40px 0;
137
+ font-size: 13.5px;
138
+ }
139
+ .content {
140
+ flex: 1 1 auto;
141
+ min-width: 0;
142
+ max-width: 760px;
143
+ padding: 40px 0 96px;
144
+ }
145
+ .group {
146
+ margin-bottom: 26px;
147
+ }
148
+ .group-title {
149
+ margin: 0 0 10px;
150
+ color: var(--muted);
151
+ font-family: var(--font-mono);
152
+ font-size: 12px;
153
+ letter-spacing: 0.09em;
154
+ text-transform: uppercase;
155
+ }
156
+ ul {
157
+ margin: 0;
158
+ padding: 0;
159
+ list-style: none;
160
+ }
161
+ .sidebar li + li {
162
+ margin-top: 1px;
163
+ }
164
+ .sidebar a {
165
+ display: block;
166
+ padding: 6px 10px;
167
+ border-left: 2px solid transparent;
168
+ border-radius: 0 7px 7px 0;
169
+ color: var(--muted);
170
+ font-size: 14px;
171
+ }
172
+ .sidebar a:hover {
173
+ color: var(--heading);
174
+ }
175
+ .sidebar a[aria-current='page'] {
176
+ border-left-color: var(--accent);
177
+ background: var(--accent-soft);
178
+ color: var(--heading);
179
+ }
180
+ .toc ul {
181
+ display: flex;
182
+ flex-direction: column;
183
+ gap: 7px;
184
+ padding-left: 14px;
185
+ border-left: 1px solid var(--border);
186
+ }
187
+ .toc a {
188
+ color: var(--muted);
189
+ }
190
+ .toc a:hover {
191
+ color: var(--heading);
192
+ }
193
+ .crumb ol {
194
+ display: flex;
195
+ flex-wrap: wrap;
196
+ gap: 8px;
197
+ margin: 0 0 14px;
198
+ padding: 0;
199
+ list-style: none;
200
+ color: var(--muted);
201
+ font-size: 13px;
202
+ }
203
+ .crumb li + li::before {
204
+ content: '/';
205
+ margin-right: 8px;
206
+ }
207
+ .crumb [aria-current] {
208
+ color: var(--text);
209
+ }
210
+ h1 {
211
+ margin: 0 0 12px;
212
+ color: var(--heading);
213
+ font-size: clamp(28px, 3.4vw, 38px);
214
+ font-weight: 600;
215
+ letter-spacing: -0.025em;
216
+ line-height: 1.15;
217
+ }
218
+ .lead {
219
+ margin: 0 0 36px;
220
+ font-size: 17.5px;
221
+ }
222
+ .pager {
223
+ display: grid;
224
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
225
+ gap: 14px;
226
+ margin-top: 56px;
227
+ }
228
+ .pager a {
229
+ display: block;
230
+ padding: 14px 18px;
231
+ border: 1px solid var(--border);
232
+ border-radius: 12px;
233
+ background: var(--surface);
234
+ }
235
+ .pager a:hover {
236
+ border-color: var(--border-strong);
237
+ }
238
+ .pager .next {
239
+ text-align: right;
240
+ }
241
+ .dir {
242
+ display: block;
243
+ margin-bottom: 3px;
244
+ color: var(--muted);
245
+ font-size: 12px;
246
+ }
247
+ .label {
248
+ color: var(--heading);
249
+ font-size: 15px;
250
+ font-weight: 500;
251
+ }
252
+ .meta {
253
+ margin: 28px 0 0;
254
+ color: var(--muted);
255
+ font-size: 13.5px;
256
+ }
257
+ /* Next to muted text the accent alone is not distinguishable enough (WCAG 1.4.1). */
258
+ .meta a {
259
+ text-decoration: underline;
260
+ text-underline-offset: 2px;
261
+ text-decoration-thickness: 1px;
262
+ }
263
+ @media (max-width: 1100px) {
264
+ .toc {
265
+ display: none;
266
+ }
267
+ }
268
+ @media (max-width: 800px) {
269
+ .docs {
270
+ flex-direction: column;
271
+ align-items: stretch;
272
+ gap: 0;
273
+ }
274
+ .sidebar {
275
+ flex: none;
276
+ width: 100%;
277
+ padding: 24px 0 0;
278
+ border-bottom: 1px solid var(--border);
279
+ }
280
+ .sidebar nav {
281
+ display: grid;
282
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
283
+ gap: 0 24px;
284
+ }
285
+ }
286
+ </style>
package/lib/ansi.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Minimal ANSI → HTML conversion for the Terminal component: SGR colours
3
+ * (16-colour palette as classes, 256-colour and 24-bit colour as inline
4
+ * styles), bold, dim, italic, underline and carriage-return overwrites. Other
5
+ * escape sequences are dropped. Projects with their own renderer (e.g.
6
+ * runemark) pass its HTML to <Terminal> instead.
7
+ */
8
+
9
+ interface Style {
10
+ fg: number | null;
11
+ /** A 256-colour or 24-bit foreground, as a CSS colour; wins over `fg`. */
12
+ fgColor: string | null;
13
+ /** A 256-colour or 24-bit background, as a CSS colour. */
14
+ bgColor: string | null;
15
+ bold: boolean;
16
+ dim: boolean;
17
+ italic: boolean;
18
+ underline: boolean;
19
+ }
20
+
21
+ interface Cell {
22
+ ch: string;
23
+ cls: string;
24
+ css: string;
25
+ }
26
+
27
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI control sequences is the point
28
+ const TOKEN = /\x1b\[([0-9;]*)([A-Za-z])|\x1b\][^\x07]*(?:\x07|\x1b\\)|\r|[^\x1b\r]+/g;
29
+
30
+ const plain = (): Style => ({
31
+ fg: null,
32
+ fgColor: null,
33
+ bgColor: null,
34
+ bold: false,
35
+ dim: false,
36
+ italic: false,
37
+ underline: false,
38
+ });
39
+
40
+ const hex = (r: number, g: number, b: number) =>
41
+ `#${[r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`;
42
+
43
+ /** The xterm 256-colour cube and grey ramp; 0–15 are the theme's palette. */
44
+ function color256(index: number): string | number {
45
+ if (index < 16) return index;
46
+ if (index < 232) {
47
+ const i = index - 16;
48
+ const level = (n: number) => (n === 0 ? 0 : 55 + n * 40);
49
+ return hex(level(Math.floor(i / 36)), level(Math.floor(i / 6) % 6), level(i % 6));
50
+ }
51
+ const grey = 8 + (index - 232) * 10;
52
+ return hex(grey, grey, grey);
53
+ }
54
+
55
+ /**
56
+ * `38;5;n` / `38;2;r;g;b` (and 48 for the background) starting at `codes[i]`.
57
+ * Returns how many codes it consumed after the 38/48 itself.
58
+ */
59
+ function extendedColour(style: Style, codes: number[], i: number): number {
60
+ const background = codes[i] === 48;
61
+ let value: string | number | null = null;
62
+ let used = 0;
63
+ if (codes[i + 1] === 5) {
64
+ value = color256(codes[i + 2] ?? 0);
65
+ used = 2;
66
+ } else if (codes[i + 1] === 2) {
67
+ value = hex(codes[i + 2] ?? 0, codes[i + 3] ?? 0, codes[i + 4] ?? 0);
68
+ used = 4;
69
+ }
70
+ if (value === null) return used;
71
+ if (background) {
72
+ // Palette backgrounds (40–47) are not rendered either; keep the two consistent.
73
+ style.bgColor = typeof value === 'string' ? value : null;
74
+ } else if (typeof value === 'number') {
75
+ paletteForeground(style, value);
76
+ } else {
77
+ style.fgColor = value;
78
+ }
79
+ return used;
80
+ }
81
+
82
+ /** A palette foreground (or none) replaces any 256/24-bit one. */
83
+ function paletteForeground(style: Style, index: number | null): void {
84
+ style.fg = index;
85
+ style.fgColor = null;
86
+ }
87
+
88
+ function applySgr(style: Style, params: string): void {
89
+ const codes = params === '' ? [0] : params.split(';').map(Number);
90
+ for (let i = 0; i < codes.length; i++) {
91
+ const c = codes[i];
92
+ if (c === 0) Object.assign(style, plain());
93
+ else if (c === 1) style.bold = true;
94
+ else if (c === 2) style.dim = true;
95
+ else if (c === 3) style.italic = true;
96
+ else if (c === 4) style.underline = true;
97
+ else if (c === 22) style.bold = style.dim = false;
98
+ else if (c === 23) style.italic = false;
99
+ else if (c === 24) style.underline = false;
100
+ else if (c >= 30 && c <= 37) paletteForeground(style, c - 30);
101
+ else if (c >= 90 && c <= 97) paletteForeground(style, c - 90 + 8);
102
+ else if (c === 39) paletteForeground(style, null);
103
+ else if (c === 49) style.bgColor = null;
104
+ else if (c === 38 || c === 48) i += extendedColour(style, codes, i);
105
+ }
106
+ }
107
+
108
+ function className(style: Style): string {
109
+ const cls: string[] = [];
110
+ if (style.bold) cls.push('ansi-bold');
111
+ if (style.dim) cls.push('ansi-dim');
112
+ if (style.italic) cls.push('ansi-italic');
113
+ if (style.underline) cls.push('ansi-underline');
114
+ if (style.fg !== null && style.fgColor === null) cls.push(`ansi-fg-${style.fg}`);
115
+ return cls.join(' ');
116
+ }
117
+
118
+ function inlineStyle(style: Style): string {
119
+ const css: string[] = [];
120
+ if (style.fgColor !== null) css.push(`color:${style.fgColor}`);
121
+ if (style.bgColor !== null) css.push(`background-color:${style.bgColor}`);
122
+ return css.join(';');
123
+ }
124
+
125
+ const escapeHtml = (s: string) =>
126
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
127
+
128
+ function renderLine(cells: Cell[]): string {
129
+ let html = '';
130
+ let i = 0;
131
+ while (i < cells.length) {
132
+ const { cls, css } = cells[i];
133
+ let text = '';
134
+ while (i < cells.length && cells[i].cls === cls && cells[i].css === css) text += cells[i++].ch;
135
+ const attrs = (cls ? ` class="${cls}"` : '') + (css ? ` style="${css}"` : '');
136
+ html += attrs ? `<span${attrs}>${escapeHtml(text)}</span>` : escapeHtml(text);
137
+ }
138
+ return html;
139
+ }
140
+
141
+ export function ansiToHtml(input: string): string {
142
+ const style = plain();
143
+ return input
144
+ .replace(/\r\n/g, '\n')
145
+ .replace(/\n$/, '')
146
+ .split('\n')
147
+ .map((line) => {
148
+ const cells: Cell[] = [];
149
+ let col = 0;
150
+ for (const [token, params, command] of line.matchAll(TOKEN)) {
151
+ if (token === '\r') col = 0;
152
+ else if (command === 'm') applySgr(style, params);
153
+ else if (token[0] !== '\x1b') {
154
+ const cls = className(style);
155
+ const css = inlineStyle(style);
156
+ for (const ch of token) cells[col++] = { ch, cls, css };
157
+ }
158
+ }
159
+ return renderLine(cells);
160
+ })
161
+ .join('\n');
162
+ }
163
+
164
+ /** Show escape sequences literally, for displaying ANSI source files. */
165
+ export function escapeAnsi(input: string): string {
166
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is replaced by its literal notation
167
+ return input.replace(/\x1b/g, '\\x1b').replace(/\r/g, '\\r').replace(/\n$/, '');
168
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Parses a Keep-a-Changelog style CHANGELOG.md:
3
+ *
4
+ * ## [0.4.0] - 2026-02-11
5
+ * ### Added
6
+ * - Item with `code` and [links](https://…)
7
+ */
8
+
9
+ export interface Change {
10
+ kind: string;
11
+ html: string;
12
+ }
13
+
14
+ export interface Release {
15
+ version: string;
16
+ date: string | null;
17
+ changes: Change[];
18
+ }
19
+
20
+ const RELEASE = /^##\s+\[?([^\]\s]+)\]?(?:\s*[-–—]\s*(\S+))?/;
21
+ const SECTION = /^###\s+(.+)/;
22
+ const ITEM = /^\s*[-*]\s+(.*)/;
23
+
24
+ const escapeHtml = (s: string) =>
25
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
26
+
27
+ /** Inline Markdown subset: `code`, **bold**, [text](url). */
28
+ export function inlineMarkdown(md: string): string {
29
+ return md
30
+ .split('`')
31
+ .map((part, i) =>
32
+ i % 2 === 1
33
+ ? `<code>${escapeHtml(part)}</code>`
34
+ : escapeHtml(part)
35
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
36
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>')
37
+ )
38
+ .join('');
39
+ }
40
+
41
+ export function parseChangelog(md: string): Release[] {
42
+ const releases: Release[] = [];
43
+ let kind = 'changed';
44
+ let item: string | null = null;
45
+
46
+ const flush = () => {
47
+ if (item !== null && releases.length > 0) {
48
+ releases[releases.length - 1].changes.push({ kind, html: inlineMarkdown(item.trim()) });
49
+ }
50
+ item = null;
51
+ };
52
+
53
+ for (const line of md.split('\n')) {
54
+ const release = RELEASE.exec(line);
55
+ const section = SECTION.exec(line);
56
+ const listItem = ITEM.exec(line);
57
+ if (release) {
58
+ flush();
59
+ releases.push({ version: release[1], date: release[2] ?? null, changes: [] });
60
+ } else if (section) {
61
+ flush();
62
+ kind = section[1].trim().toLowerCase();
63
+ } else if (listItem) {
64
+ flush();
65
+ item = listItem[1];
66
+ } else if (item !== null && line.trim() !== '') {
67
+ item += ` ${line.trim()}`;
68
+ } else {
69
+ flush();
70
+ }
71
+ }
72
+ flush();
73
+ return releases.filter((r) => r.changes.length > 0);
74
+ }
75
+
76
+ export function formatDate(iso: string, locale = 'en-GB'): string {
77
+ const date = new Date(`${iso}T00:00:00Z`);
78
+ if (Number.isNaN(date.getTime())) return iso;
79
+ return date.toLocaleDateString(locale, {
80
+ day: 'numeric',
81
+ month: 'long',
82
+ year: 'numeric',
83
+ timeZone: 'UTC',
84
+ });
85
+ }
package/lib/docs.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { CollectionEntry } from 'astro:content';
2
+ import { url } from './url';
3
+
4
+ export interface DocsNavItem {
5
+ id: string;
6
+ label: string;
7
+ href: string;
8
+ }
9
+
10
+ export interface DocsNavGroup {
11
+ label: string;
12
+ items: DocsNavItem[];
13
+ }
14
+
15
+ const humanize = (dir: string) => dir.replace(/[-_]/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
16
+
17
+ /** The docs page at docs/index.md is served at /docs/. */
18
+ export const docSlug = (id: string) => (id === 'index' ? undefined : id);
19
+
20
+ export const docHref = (id: string) => url(id === 'index' ? 'docs/' : `docs/${id}/`);
21
+
22
+ /**
23
+ * Sidebar groups come from the folders in docs/. Group order and labels follow
24
+ * the `docsGroups` option; unlisted folders follow alphabetically. Root-level
25
+ * pages join the first group.
26
+ */
27
+ export function buildDocsNav(
28
+ entries: CollectionEntry<'docs'>[],
29
+ groups: Record<string, string>
30
+ ): DocsNavGroup[] {
31
+ const configured = Object.keys(groups);
32
+ const byGroup = new Map<string, CollectionEntry<'docs'>[]>();
33
+
34
+ for (const entry of entries) {
35
+ const dir = entry.id.includes('/') ? entry.id.split('/')[0] : (configured[0] ?? '');
36
+ byGroup.set(dir, [...(byGroup.get(dir) ?? []), entry]);
37
+ }
38
+
39
+ const order = [
40
+ ...configured.filter((dir) => byGroup.has(dir)),
41
+ ...[...byGroup.keys()].filter((dir) => !configured.includes(dir)).sort(),
42
+ ];
43
+
44
+ return order.map((dir) => ({
45
+ label: groups[dir] ?? humanize(dir),
46
+ items: (byGroup.get(dir) ?? [])
47
+ .sort((a, b) => a.data.order - b.data.order || a.data.title.localeCompare(b.data.title))
48
+ .map((entry) => ({
49
+ id: entry.id,
50
+ label: entry.data.sidebarLabel ?? entry.data.title,
51
+ href: docHref(entry.id),
52
+ })),
53
+ }));
54
+ }