@raystack/chronicle 0.5.3 → 0.6.0

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 (74) hide show
  1. package/dist/cli/index.js +260 -81
  2. package/package.json +8 -6
  3. package/src/cli/commands/build.ts +5 -8
  4. package/src/cli/commands/dev.ts +5 -6
  5. package/src/cli/commands/init.test.ts +77 -0
  6. package/src/cli/commands/init.ts +73 -40
  7. package/src/cli/commands/serve.ts +6 -9
  8. package/src/cli/commands/start.ts +5 -5
  9. package/src/cli/utils/config.ts +6 -12
  10. package/src/cli/utils/scaffold.test.ts +179 -0
  11. package/src/cli/utils/scaffold.ts +70 -9
  12. package/src/components/api/field-row.tsx +1 -1
  13. package/src/components/api/field-section.tsx +2 -2
  14. package/src/components/mdx/index.tsx +1 -1
  15. package/src/components/mdx/mermaid.tsx +24 -21
  16. package/src/components/ui/breadcrumbs.tsx +4 -2
  17. package/src/components/ui/client-theme-switcher.tsx +21 -4
  18. package/src/components/ui/search.module.css +16 -41
  19. package/src/components/ui/search.tsx +30 -41
  20. package/src/lib/config.test.ts +493 -0
  21. package/src/lib/config.ts +123 -22
  22. package/src/lib/head.tsx +23 -5
  23. package/src/lib/llms.test.ts +94 -0
  24. package/src/lib/llms.ts +41 -0
  25. package/src/lib/navigation.test.ts +94 -0
  26. package/src/lib/navigation.ts +51 -0
  27. package/src/lib/page-context.tsx +79 -32
  28. package/src/lib/route-resolver.test.ts +173 -0
  29. package/src/lib/route-resolver.ts +73 -0
  30. package/src/lib/source.ts +94 -1
  31. package/src/lib/version-source.test.ts +163 -0
  32. package/src/lib/version-source.ts +101 -0
  33. package/src/pages/ApiPage.tsx +1 -1
  34. package/src/pages/DocsLayout.tsx +24 -3
  35. package/src/pages/DocsPage.tsx +7 -7
  36. package/src/pages/LandingPage.module.css +56 -0
  37. package/src/pages/LandingPage.tsx +39 -0
  38. package/src/pages/NotFound.module.css +3 -0
  39. package/src/pages/NotFound.tsx +9 -12
  40. package/src/server/App.tsx +21 -23
  41. package/src/server/api/{page/[...slug].ts → page.ts} +7 -3
  42. package/src/server/api/search.ts +51 -24
  43. package/src/server/api/specs.ts +17 -5
  44. package/src/server/entry-client.tsx +42 -14
  45. package/src/server/entry-server.tsx +35 -13
  46. package/src/server/plugins/telemetry.ts +47 -7
  47. package/src/server/routes/[...slug].md.ts +0 -6
  48. package/src/server/routes/[version]/llms.txt.ts +26 -0
  49. package/src/server/routes/llms.txt.ts +10 -13
  50. package/src/server/routes/og.tsx +2 -2
  51. package/src/server/routes/sitemap.xml.ts +14 -6
  52. package/src/server/vite-config.ts +5 -5
  53. package/src/themes/default/ContentDirButtons.tsx +66 -0
  54. package/src/themes/default/Layout.module.css +187 -40
  55. package/src/themes/default/Layout.tsx +166 -65
  56. package/src/themes/default/OpenInAI.tsx +112 -0
  57. package/src/themes/default/Page.module.css +30 -0
  58. package/src/themes/default/Page.tsx +1 -3
  59. package/src/themes/default/SidebarLogo.tsx +26 -0
  60. package/src/themes/default/Toc.module.css +102 -25
  61. package/src/themes/default/Toc.tsx +56 -10
  62. package/src/themes/default/VersionSwitcher.tsx +59 -0
  63. package/src/themes/paper/ContentDirDropdown.tsx +47 -0
  64. package/src/themes/paper/Layout.module.css +7 -0
  65. package/src/themes/paper/Layout.tsx +20 -13
  66. package/src/themes/paper/VersionSwitcher.tsx +60 -0
  67. package/src/types/config.ts +146 -23
  68. package/src/types/content.ts +11 -1
  69. package/src/types/theme.ts +1 -0
  70. package/src/components/ui/footer.module.css +0 -27
  71. package/src/components/ui/footer.tsx +0 -30
  72. package/src/server/api/metrics.ts +0 -23
  73. package/src/server/api/page/index.ts +0 -1
  74. package/src/server/telemetry.ts +0 -49
@@ -0,0 +1,493 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { chronicleConfigSchema } from '@/types'
3
+ import {
4
+ getAllVersions,
5
+ getApiConfigsForVersion,
6
+ getLandingEntries,
7
+ getLatestContentRoots,
8
+ getVersionContentRoots,
9
+ loadConfig,
10
+ } from './config'
11
+
12
+ type GlobalWithRaw = typeof globalThis & {
13
+ __CHRONICLE_CONFIG_RAW__?: string | null
14
+ }
15
+
16
+ const g = globalThis as GlobalWithRaw
17
+
18
+ const minimal = {
19
+ site: { title: 'My Docs' },
20
+ content: [{ dir: 'docs', label: 'Docs' }],
21
+ }
22
+
23
+ describe('chronicleConfigSchema', () => {
24
+ test('parses minimal single-content config', () => {
25
+ const parsed = chronicleConfigSchema.parse(minimal)
26
+ expect(parsed.site.title).toBe('My Docs')
27
+ expect(parsed.content).toEqual([{ dir: 'docs', label: 'Docs' }])
28
+ })
29
+
30
+ test('parses multi-content config', () => {
31
+ const parsed = chronicleConfigSchema.parse({
32
+ ...minimal,
33
+ content: [
34
+ { dir: 'docs', label: 'Docs' },
35
+ { dir: 'dev', label: 'Dev Docs' },
36
+ ],
37
+ })
38
+ expect(parsed.content).toHaveLength(2)
39
+ })
40
+
41
+ test('parses versioned config with badge and defaults variant to accent', () => {
42
+ const parsed = chronicleConfigSchema.parse({
43
+ ...minimal,
44
+ latest: { label: '3.0' },
45
+ versions: [
46
+ {
47
+ dir: 'v1',
48
+ label: '1.0',
49
+ badge: { label: 'deprecated' },
50
+ content: [{ dir: 'docs', label: 'Docs' }],
51
+ },
52
+ ],
53
+ })
54
+ expect(parsed.versions?.[0].badge).toEqual({
55
+ label: 'deprecated',
56
+ variant: 'accent',
57
+ })
58
+ })
59
+
60
+ test('accepts explicit badge variant', () => {
61
+ const parsed = chronicleConfigSchema.parse({
62
+ ...minimal,
63
+ latest: { label: '3.0' },
64
+ versions: [
65
+ {
66
+ dir: 'v1',
67
+ label: '1.0',
68
+ badge: { label: 'deprecated', variant: 'warning' },
69
+ content: [{ dir: 'docs', label: 'Docs' }],
70
+ },
71
+ ],
72
+ })
73
+ expect(parsed.versions?.[0].badge?.variant).toBe('warning')
74
+ })
75
+
76
+ test('rejects unknown top-level field (legacy title)', () => {
77
+ expect(() =>
78
+ chronicleConfigSchema.parse({ ...minimal, title: 'My Docs' }),
79
+ ).toThrow()
80
+ })
81
+
82
+ test('rejects string form of content', () => {
83
+ expect(() =>
84
+ chronicleConfigSchema.parse({ site: { title: 'x' }, content: '.' }),
85
+ ).toThrow()
86
+ })
87
+
88
+ test('rejects empty content array', () => {
89
+ expect(() =>
90
+ chronicleConfigSchema.parse({ site: { title: 'x' }, content: [] }),
91
+ ).toThrow()
92
+ })
93
+
94
+ test('rejects invalid dir names (hidden, path-shaped, whitespace)', () => {
95
+ const bad = ['.', '..', 'foo/bar', 'foo\\bar', '.hidden', ' docs', 'docs ', 'do cs', '']
96
+ for (const dir of bad) {
97
+ expect(() =>
98
+ chronicleConfigSchema.parse({
99
+ site: { title: 'x' },
100
+ content: [{ dir, label: 'Docs' }],
101
+ }),
102
+ ).toThrow()
103
+ }
104
+ })
105
+
106
+ test('accepts standard dir names (letters, digits, dash, underscore)', () => {
107
+ for (const dir of ['docs', 'dev-docs', 'v1', 'v1_beta', 'api2']) {
108
+ expect(() =>
109
+ chronicleConfigSchema.parse({
110
+ site: { title: 'x' },
111
+ content: [{ dir, label: 'x' }],
112
+ }),
113
+ ).not.toThrow()
114
+ }
115
+ })
116
+
117
+ test('rejects version dir overlapping a top-level content dir', () => {
118
+ expect(() =>
119
+ chronicleConfigSchema.parse({
120
+ site: { title: 'x' },
121
+ content: [{ dir: 'v1', label: 'V1 docs' }],
122
+ latest: { label: '2.0' },
123
+ versions: [
124
+ {
125
+ dir: 'v1',
126
+ label: '1.0',
127
+ content: [{ dir: 'docs', label: 'Docs' }],
128
+ },
129
+ ],
130
+ }),
131
+ ).toThrow(/must not overlap/)
132
+ })
133
+
134
+ test('rejects reserved route segments as dir names', () => {
135
+ expect(() =>
136
+ chronicleConfigSchema.parse({
137
+ site: { title: 'x' },
138
+ content: [{ dir: 'apis', label: 'API' }],
139
+ }),
140
+ ).toThrow(/reserved route segment/)
141
+
142
+ expect(() =>
143
+ chronicleConfigSchema.parse({
144
+ site: { title: 'x' },
145
+ content: [{ dir: 'docs', label: 'Docs' }],
146
+ latest: { label: '2.0' },
147
+ versions: [
148
+ {
149
+ dir: 'apis',
150
+ label: '1.0',
151
+ content: [{ dir: 'docs', label: 'Docs' }],
152
+ },
153
+ ],
154
+ }),
155
+ ).toThrow(/reserved route segment/)
156
+ })
157
+
158
+ test('rejects "." or ".." version dir', () => {
159
+ expect(() =>
160
+ chronicleConfigSchema.parse({
161
+ site: { title: 'x' },
162
+ content: [{ dir: 'docs', label: 'Docs' }],
163
+ latest: { label: '2.0' },
164
+ versions: [
165
+ {
166
+ dir: '.',
167
+ label: '1.0',
168
+ content: [{ dir: 'docs', label: 'Docs' }],
169
+ },
170
+ ],
171
+ }),
172
+ ).toThrow()
173
+ })
174
+
175
+ test('rejects versions without latest', () => {
176
+ expect(() =>
177
+ chronicleConfigSchema.parse({
178
+ ...minimal,
179
+ versions: [
180
+ {
181
+ dir: 'v1',
182
+ label: '1.0',
183
+ content: [{ dir: 'docs', label: 'Docs' }],
184
+ },
185
+ ],
186
+ }),
187
+ ).toThrow(/latest is required/)
188
+ })
189
+
190
+ test('rejects duplicate content[].dir', () => {
191
+ expect(() =>
192
+ chronicleConfigSchema.parse({
193
+ ...minimal,
194
+ content: [
195
+ { dir: 'docs', label: 'A' },
196
+ { dir: 'docs', label: 'B' },
197
+ ],
198
+ }),
199
+ ).toThrow(/content\[\]\.dir must be unique/)
200
+ })
201
+
202
+ test('rejects duplicate versions[].dir', () => {
203
+ expect(() =>
204
+ chronicleConfigSchema.parse({
205
+ ...minimal,
206
+ latest: { label: '3.0' },
207
+ versions: [
208
+ { dir: 'v1', label: '1', content: [{ dir: 'docs', label: 'd' }] },
209
+ { dir: 'v1', label: '1b', content: [{ dir: 'docs', label: 'd' }] },
210
+ ],
211
+ }),
212
+ ).toThrow(/versions\[\]\.dir must be unique/)
213
+ })
214
+
215
+ test('rejects duplicate content dirs within a version', () => {
216
+ expect(() =>
217
+ chronicleConfigSchema.parse({
218
+ ...minimal,
219
+ latest: { label: '3.0' },
220
+ versions: [
221
+ {
222
+ dir: 'v1',
223
+ label: '1',
224
+ content: [
225
+ { dir: 'docs', label: 'A' },
226
+ { dir: 'docs', label: 'B' },
227
+ ],
228
+ },
229
+ ],
230
+ }),
231
+ ).toThrow(/unique within each version/)
232
+ })
233
+
234
+ test('rejects invalid badge variant', () => {
235
+ expect(() =>
236
+ chronicleConfigSchema.parse({
237
+ ...minimal,
238
+ latest: { label: '3.0' },
239
+ versions: [
240
+ {
241
+ dir: 'v1',
242
+ label: '1',
243
+ badge: { label: 'x', variant: 'info' },
244
+ content: [{ dir: 'docs', label: 'd' }],
245
+ },
246
+ ],
247
+ }),
248
+ ).toThrow()
249
+ })
250
+ })
251
+
252
+ describe('getLatestContentRoots', () => {
253
+ test('maps each content entry to content/<dir>', () => {
254
+ const cfg = chronicleConfigSchema.parse({
255
+ ...minimal,
256
+ content: [
257
+ { dir: 'docs', label: 'Docs' },
258
+ { dir: 'dev', label: 'Dev Docs' },
259
+ ],
260
+ })
261
+ const roots = getLatestContentRoots(cfg)
262
+ expect(roots).toEqual([
263
+ {
264
+ versionDir: null,
265
+ versionLabel: null,
266
+ contentDir: 'docs',
267
+ contentLabel: 'Docs',
268
+ fsPath: 'content/docs',
269
+ urlPrefix: '/docs',
270
+ },
271
+ {
272
+ versionDir: null,
273
+ versionLabel: null,
274
+ contentDir: 'dev',
275
+ contentLabel: 'Dev Docs',
276
+ fsPath: 'content/dev',
277
+ urlPrefix: '/dev',
278
+ },
279
+ ])
280
+ })
281
+
282
+ test('includes versionLabel when latest is set', () => {
283
+ const cfg = chronicleConfigSchema.parse({
284
+ ...minimal,
285
+ latest: { label: '3.0' },
286
+ })
287
+ expect(getLatestContentRoots(cfg)[0].versionLabel).toBe('3.0')
288
+ })
289
+ })
290
+
291
+ describe('getVersionContentRoots', () => {
292
+ test('resolves versions/<v>/<dir> and preserves config order', () => {
293
+ const cfg = chronicleConfigSchema.parse({
294
+ ...minimal,
295
+ latest: { label: '3.0' },
296
+ versions: [
297
+ {
298
+ dir: 'v1',
299
+ label: '1.0',
300
+ content: [
301
+ { dir: 'dev', label: 'Developer Guide' },
302
+ { dir: 'docs', label: 'Docs' },
303
+ ],
304
+ },
305
+ ],
306
+ })
307
+ const roots = getVersionContentRoots(cfg, 'v1')
308
+ expect(roots.map((r) => r.fsPath)).toEqual([
309
+ 'versions/v1/dev',
310
+ 'versions/v1/docs',
311
+ ])
312
+ expect(roots.map((r) => r.urlPrefix)).toEqual(['/v1/dev', '/v1/docs'])
313
+ expect(roots[0].contentLabel).toBe('Developer Guide')
314
+ })
315
+
316
+ test('returns empty array for unknown version', () => {
317
+ const cfg = chronicleConfigSchema.parse(minimal)
318
+ expect(getVersionContentRoots(cfg, 'v1')).toEqual([])
319
+ })
320
+ })
321
+
322
+ describe('getAllVersions', () => {
323
+ test('returns latest first then versions in config order', () => {
324
+ const cfg = chronicleConfigSchema.parse({
325
+ ...minimal,
326
+ latest: { label: '3.0' },
327
+ versions: [
328
+ {
329
+ dir: 'v2',
330
+ label: '2.0',
331
+ content: [{ dir: 'docs', label: 'Docs' }],
332
+ },
333
+ {
334
+ dir: 'v1',
335
+ label: '1.0',
336
+ badge: { label: 'deprecated', variant: 'warning' },
337
+ content: [{ dir: 'docs', label: 'Docs' }],
338
+ },
339
+ ],
340
+ })
341
+ const all = getAllVersions(cfg)
342
+ expect(all).toEqual([
343
+ { dir: null, label: '3.0', isLatest: true },
344
+ { dir: 'v2', label: '2.0', isLatest: false },
345
+ {
346
+ dir: 'v1',
347
+ label: '1.0',
348
+ badge: { label: 'deprecated', variant: 'warning' },
349
+ isLatest: false,
350
+ },
351
+ ])
352
+ })
353
+
354
+ test('always includes the latest entry, even without latest or versions', () => {
355
+ const cfg = chronicleConfigSchema.parse(minimal)
356
+ expect(getAllVersions(cfg)).toEqual([
357
+ { dir: null, label: '', isLatest: true },
358
+ ])
359
+ })
360
+ })
361
+
362
+ describe('getLandingEntries', () => {
363
+ test('returns labels + unprefixed hrefs for latest', () => {
364
+ const cfg = chronicleConfigSchema.parse({
365
+ site: { title: 'x' },
366
+ content: [
367
+ { dir: 'docs', label: 'Docs' },
368
+ { dir: 'dev', label: 'Dev' },
369
+ ],
370
+ })
371
+ expect(getLandingEntries(cfg, null)).toEqual([
372
+ { label: 'Docs', href: '/docs', contentDir: 'docs' },
373
+ { label: 'Dev', href: '/dev', contentDir: 'dev' },
374
+ ])
375
+ })
376
+
377
+ test('returns versioned hrefs for a version', () => {
378
+ const cfg = chronicleConfigSchema.parse({
379
+ site: { title: 'x' },
380
+ content: [{ dir: 'docs', label: 'Docs' }],
381
+ latest: { label: '3.0' },
382
+ versions: [
383
+ {
384
+ dir: 'v1',
385
+ label: '1.0',
386
+ content: [
387
+ { dir: 'dev', label: 'Developer Guide' },
388
+ { dir: 'docs', label: 'Docs' },
389
+ ],
390
+ },
391
+ ],
392
+ })
393
+ expect(getLandingEntries(cfg, 'v1')).toEqual([
394
+ { label: 'Developer Guide', href: '/v1/dev', contentDir: 'dev' },
395
+ { label: 'Docs', href: '/v1/docs', contentDir: 'docs' },
396
+ ])
397
+ })
398
+
399
+ test('returns empty array for unknown version', () => {
400
+ const cfg = chronicleConfigSchema.parse({
401
+ site: { title: 'x' },
402
+ content: [{ dir: 'docs', label: 'Docs' }],
403
+ })
404
+ expect(getLandingEntries(cfg, 'v9')).toEqual([])
405
+ })
406
+ })
407
+
408
+ describe('getApiConfigsForVersion', () => {
409
+ const apiFixture = {
410
+ name: 'Petstore',
411
+ spec: './petstore.json',
412
+ basePath: '/apis',
413
+ server: { url: 'https://petstore.example.com' },
414
+ }
415
+
416
+ test('returns config.api for latest (null)', () => {
417
+ const cfg = chronicleConfigSchema.parse({
418
+ site: { title: 'x' },
419
+ content: [{ dir: 'docs', label: 'Docs' }],
420
+ api: [apiFixture],
421
+ })
422
+ expect(getApiConfigsForVersion(cfg, null)).toEqual([apiFixture])
423
+ })
424
+
425
+ test('returns versions[].api for a matching version', () => {
426
+ const versionedApi = { ...apiFixture, spec: './v1-petstore.json' }
427
+ const cfg = chronicleConfigSchema.parse({
428
+ site: { title: 'x' },
429
+ content: [{ dir: 'docs', label: 'Docs' }],
430
+ latest: { label: '3.0' },
431
+ versions: [
432
+ {
433
+ dir: 'v1',
434
+ label: '1.0',
435
+ content: [{ dir: 'docs', label: 'Docs' }],
436
+ api: [versionedApi],
437
+ },
438
+ ],
439
+ })
440
+ expect(getApiConfigsForVersion(cfg, 'v1')).toEqual([versionedApi])
441
+ })
442
+
443
+ test('returns [] for unknown version or missing api', () => {
444
+ const cfg = chronicleConfigSchema.parse({
445
+ site: { title: 'x' },
446
+ content: [{ dir: 'docs', label: 'Docs' }],
447
+ })
448
+ expect(getApiConfigsForVersion(cfg, 'v9')).toEqual([])
449
+ expect(getApiConfigsForVersion(cfg, null)).toEqual([])
450
+ })
451
+ })
452
+
453
+ describe('loadConfig', () => {
454
+ beforeEach(() => {
455
+ delete g.__CHRONICLE_CONFIG_RAW__
456
+ })
457
+
458
+ afterEach(() => {
459
+ delete g.__CHRONICLE_CONFIG_RAW__
460
+ })
461
+
462
+ test('returns default config when raw is undefined', () => {
463
+ const cfg = loadConfig()
464
+ expect(cfg.site.title).toBe('Documentation')
465
+ expect(cfg.content).toEqual([{ dir: 'docs', label: 'Docs' }])
466
+ })
467
+
468
+ test('returns default config when raw is null', () => {
469
+ g.__CHRONICLE_CONFIG_RAW__ = null
470
+ const cfg = loadConfig()
471
+ expect(cfg.site.title).toBe('Documentation')
472
+ })
473
+
474
+ test('parses yaml raw string', () => {
475
+ g.__CHRONICLE_CONFIG_RAW__ = `
476
+ site:
477
+ title: Yaml Docs
478
+ content:
479
+ - dir: docs
480
+ label: Docs
481
+ - dir: dev
482
+ label: Dev
483
+ `
484
+ const cfg = loadConfig()
485
+ expect(cfg.site.title).toBe('Yaml Docs')
486
+ expect(cfg.content).toHaveLength(2)
487
+ })
488
+
489
+ test('throws on invalid yaml config', () => {
490
+ g.__CHRONICLE_CONFIG_RAW__ = 'title: Legacy'
491
+ expect(() => loadConfig()).toThrow()
492
+ })
493
+ })
package/src/lib/config.ts CHANGED
@@ -1,32 +1,133 @@
1
- import { parse } from 'yaml';
2
- import type { ChronicleConfig } from '@/types';
1
+ import { parse } from 'yaml'
2
+ import {
3
+ type ApiConfig,
4
+ type BadgeConfig,
5
+ type ChronicleConfig,
6
+ chronicleConfigSchema,
7
+ } from '@/types'
3
8
 
4
- const defaultConfig: ChronicleConfig = {
5
- title: 'Documentation',
9
+ const defaultConfig: ChronicleConfig = chronicleConfigSchema.parse({
10
+ site: { title: 'Documentation' },
11
+ content: [{ dir: 'docs', label: 'Docs' }],
6
12
  theme: { name: 'default' },
7
- search: { enabled: true, placeholder: 'Search...' }
8
- };
13
+ search: { enabled: true, placeholder: 'Search...' },
14
+ })
9
15
 
10
16
  export function loadConfig(): ChronicleConfig {
11
- const raw = typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined' ? __CHRONICLE_CONFIG_RAW__ : null;
17
+ const raw =
18
+ typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined'
19
+ ? __CHRONICLE_CONFIG_RAW__
20
+ : null
12
21
 
13
- if (!raw) {
14
- return defaultConfig;
15
- }
16
-
17
- const userConfig = parse(raw) as Partial<ChronicleConfig>;
22
+ if (!raw) return defaultConfig
18
23
 
24
+ const parsed = chronicleConfigSchema.parse(parse(raw))
19
25
  return {
20
26
  ...defaultConfig,
21
- ...userConfig,
22
- theme: {
23
- name: userConfig.theme?.name ?? defaultConfig.theme!.name,
24
- colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors }
27
+ ...parsed,
28
+ theme: { ...defaultConfig.theme, ...parsed.theme },
29
+ search: { ...defaultConfig.search, ...parsed.search },
30
+ }
31
+ }
32
+
33
+ export interface ContentRoot {
34
+ versionDir: string | null
35
+ versionLabel: string | null
36
+ contentDir: string
37
+ contentLabel: string
38
+ contentIcon?: string
39
+ fsPath: string
40
+ urlPrefix: string
41
+ }
42
+
43
+ export function getLatestContentRoots(config: ChronicleConfig): ContentRoot[] {
44
+ return config.content.map((c) => ({
45
+ versionDir: null,
46
+ versionLabel: config.latest?.label ?? null,
47
+ contentDir: c.dir,
48
+ contentLabel: c.label,
49
+ contentIcon: c.icon,
50
+ fsPath: `content/${c.dir}`,
51
+ urlPrefix: `/${c.dir}`,
52
+ }))
53
+ }
54
+
55
+ export function getVersionContentRoots(
56
+ config: ChronicleConfig,
57
+ versionDir: string,
58
+ ): ContentRoot[] {
59
+ const version = config.versions?.find((v) => v.dir === versionDir)
60
+ if (!version) return []
61
+
62
+ return version.content.map((c) => ({
63
+ versionDir: version.dir,
64
+ versionLabel: version.label,
65
+ contentDir: c.dir,
66
+ contentLabel: c.label,
67
+ contentIcon: c.icon,
68
+ fsPath: `versions/${version.dir}/${c.dir}`,
69
+ urlPrefix: `/${version.dir}/${c.dir}`,
70
+ }))
71
+ }
72
+
73
+ export interface VersionDescriptor {
74
+ dir: string | null
75
+ label: string
76
+ badge?: BadgeConfig
77
+ isLatest: boolean
78
+ }
79
+
80
+ export interface LandingEntry {
81
+ label: string
82
+ href: string
83
+ contentDir: string
84
+ icon?: string
85
+ }
86
+
87
+ export function getLandingEntries(
88
+ config: ChronicleConfig,
89
+ versionDir: string | null,
90
+ ): LandingEntry[] {
91
+ const roots =
92
+ versionDir === null
93
+ ? getLatestContentRoots(config)
94
+ : getVersionContentRoots(config, versionDir)
95
+
96
+ return roots.map((r) => ({
97
+ label: r.contentLabel,
98
+ href: r.urlPrefix,
99
+ contentDir: r.contentDir,
100
+ icon: r.contentIcon,
101
+ }))
102
+ }
103
+
104
+ export function getApiConfigsForVersion(
105
+ config: ChronicleConfig,
106
+ versionDir: string | null,
107
+ ): ApiConfig[] {
108
+ if (versionDir === null) return config.api ?? []
109
+ return (
110
+ config.versions?.find((v) => v.dir === versionDir)?.api ?? []
111
+ )
112
+ }
113
+
114
+ export function getAllVersions(config: ChronicleConfig): VersionDescriptor[] {
115
+ const result: VersionDescriptor[] = [
116
+ {
117
+ dir: null,
118
+ label: config.latest?.label ?? '',
119
+ isLatest: true,
25
120
  },
26
- search: { ...defaultConfig.search, ...userConfig.search },
27
- footer: userConfig.footer,
28
- api: userConfig.api,
29
- llms: { enabled: false, ...userConfig.llms },
30
- analytics: { enabled: false, ...userConfig.analytics }
31
- };
121
+ ]
122
+
123
+ for (const v of config.versions ?? []) {
124
+ result.push({
125
+ dir: v.dir,
126
+ label: v.label,
127
+ badge: v.badge,
128
+ isLatest: false,
129
+ })
130
+ }
131
+
132
+ return result
32
133
  }
package/src/lib/head.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { useLocation } from 'react-router';
1
2
  import type { ChronicleConfig } from '@/types';
2
3
 
3
4
  export interface HeadProps {
@@ -5,17 +6,33 @@ export interface HeadProps {
5
6
  description?: string;
6
7
  config: ChronicleConfig;
7
8
  jsonLd?: Record<string, unknown>;
9
+ markdownHref?: string;
8
10
  }
9
11
 
10
- export function Head({ title, description, config, jsonLd }: HeadProps) {
11
- const fullTitle = `${title} | ${config.title}`;
12
+ export function Head({ title, description, config, jsonLd, markdownHref }: HeadProps) {
13
+ const { pathname } = useLocation();
14
+ const fullTitle = `${title} | ${config.site.title}`;
12
15
  const ogParams = new URLSearchParams({ title });
13
16
  if (description) ogParams.set('description', description);
17
+ const siteUrl = config.url ? config.url.replace(/\/$/, '') : null;
18
+ const canonical = siteUrl ? `${siteUrl}${pathname}` : null;
19
+ const ogImage = siteUrl
20
+ ? `${siteUrl}/og?${ogParams.toString()}`
21
+ : `/og?${ogParams.toString()}`;
14
22
 
15
23
  return (
16
24
  <>
17
25
  <title>{fullTitle}</title>
18
26
  {description && <meta name='description' content={description} />}
27
+ {canonical && <link rel='canonical' href={canonical} />}
28
+ {markdownHref && (
29
+ <link
30
+ rel='alternate'
31
+ type='text/markdown'
32
+ href={markdownHref}
33
+ title={`${title} (Markdown)`}
34
+ />
35
+ )}
19
36
 
20
37
  {config.url && (
21
38
  <>
@@ -23,9 +40,10 @@ export function Head({ title, description, config, jsonLd }: HeadProps) {
23
40
  {description && (
24
41
  <meta property='og:description' content={description} />
25
42
  )}
26
- <meta property='og:site_name' content={config.title} />
43
+ <meta property='og:site_name' content={config.site.title} />
27
44
  <meta property='og:type' content='website' />
28
- <meta property='og:image' content={`/og?${ogParams.toString()}`} />
45
+ {canonical && <meta property='og:url' content={canonical} />}
46
+ <meta property='og:image' content={ogImage} />
29
47
  <meta property='og:image:width' content='1200' />
30
48
  <meta property='og:image:height' content='630' />
31
49
 
@@ -34,7 +52,7 @@ export function Head({ title, description, config, jsonLd }: HeadProps) {
34
52
  {description && (
35
53
  <meta name='twitter:description' content={description} />
36
54
  )}
37
- <meta name='twitter:image' content={`/og?${ogParams.toString()}`} />
55
+ <meta name='twitter:image' content={ogImage} />
38
56
  </>
39
57
  )}
40
58