@raystack/chronicle 0.5.4 → 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.
- package/dist/cli/index.js +258 -80
- package/package.json +8 -6
- package/src/cli/commands/build.ts +5 -8
- package/src/cli/commands/dev.ts +5 -6
- package/src/cli/commands/init.test.ts +77 -0
- package/src/cli/commands/init.ts +73 -40
- package/src/cli/commands/serve.ts +6 -9
- package/src/cli/commands/start.ts +5 -5
- package/src/cli/utils/config.ts +6 -12
- package/src/cli/utils/scaffold.test.ts +179 -0
- package/src/cli/utils/scaffold.ts +70 -9
- package/src/components/api/field-row.tsx +1 -1
- package/src/components/api/field-section.tsx +2 -2
- package/src/components/mdx/index.tsx +1 -1
- package/src/components/mdx/mermaid.tsx +24 -21
- package/src/components/ui/breadcrumbs.tsx +4 -2
- package/src/components/ui/client-theme-switcher.tsx +21 -4
- package/src/components/ui/search.module.css +16 -41
- package/src/components/ui/search.tsx +30 -41
- package/src/lib/config.test.ts +493 -0
- package/src/lib/config.ts +123 -22
- package/src/lib/head.tsx +23 -5
- package/src/lib/llms.test.ts +94 -0
- package/src/lib/llms.ts +41 -0
- package/src/lib/navigation.test.ts +94 -0
- package/src/lib/navigation.ts +51 -0
- package/src/lib/page-context.tsx +51 -32
- package/src/lib/route-resolver.test.ts +173 -0
- package/src/lib/route-resolver.ts +73 -0
- package/src/lib/source.ts +94 -1
- package/src/lib/version-source.test.ts +163 -0
- package/src/lib/version-source.ts +101 -0
- package/src/pages/ApiPage.tsx +1 -1
- package/src/pages/DocsLayout.tsx +24 -3
- package/src/pages/DocsPage.tsx +3 -6
- package/src/pages/LandingPage.module.css +56 -0
- package/src/pages/LandingPage.tsx +39 -0
- package/src/pages/NotFound.tsx +2 -0
- package/src/server/App.tsx +21 -23
- package/src/server/api/page.ts +5 -1
- package/src/server/api/search.ts +51 -24
- package/src/server/api/specs.ts +17 -5
- package/src/server/entry-client.tsx +42 -14
- package/src/server/entry-server.tsx +33 -11
- package/src/server/routes/[...slug].md.ts +0 -6
- package/src/server/routes/[version]/llms.txt.ts +26 -0
- package/src/server/routes/llms.txt.ts +10 -13
- package/src/server/routes/og.tsx +2 -2
- package/src/server/routes/sitemap.xml.ts +14 -6
- package/src/server/vite-config.ts +5 -5
- package/src/themes/default/ContentDirButtons.tsx +66 -0
- package/src/themes/default/Layout.module.css +187 -40
- package/src/themes/default/Layout.tsx +166 -65
- package/src/themes/default/OpenInAI.tsx +112 -0
- package/src/themes/default/Page.module.css +30 -0
- package/src/themes/default/Page.tsx +1 -3
- package/src/themes/default/SidebarLogo.tsx +26 -0
- package/src/themes/default/Toc.module.css +102 -25
- package/src/themes/default/Toc.tsx +56 -10
- package/src/themes/default/VersionSwitcher.tsx +59 -0
- package/src/themes/paper/ContentDirDropdown.tsx +47 -0
- package/src/themes/paper/Layout.module.css +7 -0
- package/src/themes/paper/Layout.tsx +20 -13
- package/src/themes/paper/VersionSwitcher.tsx +60 -0
- package/src/types/config.ts +145 -23
- package/src/types/content.ts +11 -1
- package/src/types/theme.ts +1 -0
- package/src/components/ui/footer.module.css +0 -27
- package/src/components/ui/footer.tsx +0 -30
|
@@ -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
|
|
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 =
|
|
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
|
-
...
|
|
22
|
-
theme: {
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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:
|
|
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={
|
|
55
|
+
<meta name='twitter:image' content={ogImage} />
|
|
38
56
|
</>
|
|
39
57
|
)}
|
|
40
58
|
|