@writedocs/generator 0.1.0 → 0.2.1
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/README.md +28 -17
- package/bin/writedocs.js +119 -73
- package/package.json +85 -79
- package/src/lib/config-schema.js +485 -0
- package/src/lib/config-schema.ts +1004 -0
- package/src/lib/config.ts +1306 -2131
package/src/lib/config.ts
CHANGED
|
@@ -1,2131 +1,1306 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import matter from 'gray-matter';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
return
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
.
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
.
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
.
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
.
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
.
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
.
|
|
802
|
-
.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
if (
|
|
902
|
-
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
/** The
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
*
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
return
|
|
1194
|
-
if (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
// all" is new; a genuine mistake (frontmatter present, title forgotten)
|
|
1308
|
-
// stays a loud build error rather than being silently treated as
|
|
1309
|
-
// non-page content.
|
|
1310
|
-
//
|
|
1311
|
-
// A handful of directories are never scanned regardless of what's in
|
|
1312
|
-
// them - build output, dependencies, and writedocs' own working
|
|
1313
|
-
// directories, none of which a site author would ever intend as page
|
|
1314
|
-
// content. Only excluded at the content directory's own top level (a
|
|
1315
|
-
// hand-authored `guides/dist-notes.mdx` isn't mistaken for the build
|
|
1316
|
-
// output directory `dist/` just because a folder two levels down happens
|
|
1317
|
-
// to also be named `dist`).
|
|
1318
|
-
const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
|
|
1319
|
-
|
|
1320
|
-
/** Recursively finds every .md/.mdx file under `contentDir` that has a
|
|
1321
|
-
* frontmatter block, skipping the handful of build/dependency
|
|
1322
|
-
* directories a real content directory tends to also contain (see
|
|
1323
|
-
* EXCLUDED_TOP_LEVEL_DIRS above) - docs/ is scanned exactly like any
|
|
1324
|
-
* other folder, no special-casing. Returns POSIX-relative paths (from
|
|
1325
|
-
* `contentDir`) suitable to hand straight to Astro's `glob()` loader as
|
|
1326
|
-
* a literal `pattern` array - see content.config.ts's `pages`
|
|
1327
|
-
* collection, and [...slug].astro, which needs the identical list to
|
|
1328
|
-
* decide whether calling `getCollection('pages')` is worth doing at all
|
|
1329
|
-
* (see content.config.ts's own comment on why an empty collection still
|
|
1330
|
-
* needs to exist, just backed by a no-op loader, to avoid Astro's "does
|
|
1331
|
-
* not exist or is empty" warning). Also reused directly by
|
|
1332
|
-
* astro.config.mjs's noindex/sitemap scan, so that scan always sees
|
|
1333
|
-
* exactly the same file set that actually becomes a page - no risk of
|
|
1334
|
-
* the two drifting apart. Synchronous and re-run from scratch wherever
|
|
1335
|
-
* it's called rather than cached and shared across modules - consistent
|
|
1336
|
-
* with how loadDocsConfig() itself is already called repeatedly across
|
|
1337
|
-
* this codebase instead of threaded through as shared state, and cheap
|
|
1338
|
-
* enough in practice (a docs site's own file count) not to matter. */
|
|
1339
|
-
export function findAllPages(contentDir: string): string[] {
|
|
1340
|
-
const results: string[] = [];
|
|
1341
|
-
function walk(dir: string, relBase: string) {
|
|
1342
|
-
let entries: fs.Dirent[];
|
|
1343
|
-
try {
|
|
1344
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1345
|
-
} catch {
|
|
1346
|
-
return;
|
|
1347
|
-
}
|
|
1348
|
-
for (const entry of entries) {
|
|
1349
|
-
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1350
|
-
const abs = path.join(dir, entry.name);
|
|
1351
|
-
if (entry.isDirectory()) {
|
|
1352
|
-
if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
|
|
1353
|
-
walk(abs, rel);
|
|
1354
|
-
continue;
|
|
1355
|
-
}
|
|
1356
|
-
if (!entry.isFile() || !/\.mdx?$/i.test(entry.name)) continue;
|
|
1357
|
-
let raw: string;
|
|
1358
|
-
try {
|
|
1359
|
-
raw = fs.readFileSync(abs, 'utf-8');
|
|
1360
|
-
} catch {
|
|
1361
|
-
continue;
|
|
1362
|
-
}
|
|
1363
|
-
const { data } = matter(raw);
|
|
1364
|
-
if (Object.keys(data).length === 0) continue; // no frontmatter at all - not a page
|
|
1365
|
-
results.push(rel);
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
walk(contentDir, '');
|
|
1369
|
-
return results;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
/** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
|
|
1373
|
-
* `public/` included - auto-loaded site-wide with zero `writedocs.json`
|
|
1374
|
-
* config, on top of (not instead of) the explicit `scripts` field
|
|
1375
|
-
* (bannerSchema and friends, above). Drop a file in, it loads;
|
|
1376
|
-
* there's no field naming which ones to use, matching the same "just
|
|
1377
|
-
* works" convention `docs/`'s own file discovery already follows (see
|
|
1378
|
-
* findAllPages() above / `content-pipeline.mdx`) - a site author already
|
|
1379
|
-
* drops content files in and expects them found, rather than also
|
|
1380
|
-
* listing every one in writedocs.json.
|
|
1381
|
-
*
|
|
1382
|
-
* Two separate walks, because `public/` needs different treatment than
|
|
1383
|
-
* everywhere else:
|
|
1384
|
-
*
|
|
1385
|
-
* - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
|
|
1386
|
-
* `snippets/`, any custom folder) - reused as the walk-with-exclusions
|
|
1387
|
-
* shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
|
|
1388
|
-
* set (skipped only at the project root, same as there), so `dist/`,
|
|
1389
|
-
* `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
|
|
1390
|
-
* half only - see below) `public/` are never walked into. BaseLayout.astro
|
|
1391
|
-
* reads each one's raw content and inlines it as a `<style>`/
|
|
1392
|
-
* `<script is:inline>` tag.
|
|
1393
|
-
* - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
|
|
1394
|
-
* walked separately (starting from `<contentDir>/public` rather than
|
|
1395
|
-
* `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
|
|
1396
|
-
* doesn't apply here - there's no `public/public/` or `public/dist/`
|
|
1397
|
-
* convention to guard against). Returned as public-URL-rooted hrefs
|
|
1398
|
-
* (a leading `/`, no `public` segment - `public/custom.css` becomes
|
|
1399
|
-
* `/custom.css`) rather than content-dir-relative paths, since these
|
|
1400
|
-
* files are already served as static assets at exactly that URL once
|
|
1401
|
-
* Astro copies `public/` into the build output. BaseLayout.astro
|
|
1402
|
-
* renders these as ordinary `<link rel="stylesheet">`/`<script src>`
|
|
1403
|
-
* tags pointing at that URL instead of inlining their content -
|
|
1404
|
-
* inlining would duplicate every byte (once in the page's own HTML,
|
|
1405
|
-
* once more as the independently-fetchable static file at that same
|
|
1406
|
-
* URL) for no benefit, where a `<link>`/`<script src>` gets normal
|
|
1407
|
-
* browser caching across pages instead of repeating the content on
|
|
1408
|
-
* every single page's markup.
|
|
1409
|
-
*
|
|
1410
|
-
* Both halves are broader than they might sound - a stray `.js` file
|
|
1411
|
-
* kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
|
|
1412
|
-
* (a snippet's own local helper, an image gallery's lightbox script
|
|
1413
|
-
* someone dropped in `public/` to reference from a raw `<script src>`
|
|
1414
|
-
* in an .mdx file, say) gets auto-injected sitewide the same as a
|
|
1415
|
-
* deliberate one; there's no separate "this one's just tooling" signal
|
|
1416
|
-
* to opt out of the convention short of renaming its extension.
|
|
1417
|
-
*
|
|
1418
|
-
* Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
|
|
1419
|
-
* by their respective path for deterministic load order across rebuilds
|
|
1420
|
-
* - same reasoning as llms.txt's own alphabetical-by-slug sort (see
|
|
1421
|
-
* llms.txt.ts) - filesystem readdir order isn't guaranteed portable
|
|
1422
|
-
* across OSes or directory-walk order otherwise.
|
|
1423
|
-
*
|
|
1424
|
-
* `css`/`js` return POSIX-separated paths relative to `contentDir`, not
|
|
1425
|
-
* absolute paths or file contents - BaseLayout.astro (the sole caller)
|
|
1426
|
-
* resolves and reads each one's content itself, right before inlining
|
|
1427
|
-
* it, so a file's content is always current as of that specific
|
|
1428
|
-
* request/build rather than cached here across a `writedocs dev`
|
|
1429
|
-
* session. `publicCss`/`publicJs` return the public-URL hrefs described
|
|
1430
|
-
* above - nothing to read, Astro's own static-file serving/copy already
|
|
1431
|
-
* handles those. */
|
|
1432
|
-
export function findRootAssets(
|
|
1433
|
-
contentDir: string
|
|
1434
|
-
): { css: string[]; js: string[]; publicCss: string[]; publicJs: string[] } {
|
|
1435
|
-
const css: string[] = [];
|
|
1436
|
-
const js: string[] = [];
|
|
1437
|
-
function walk(dir: string, relBase: string) {
|
|
1438
|
-
let entries: fs.Dirent[];
|
|
1439
|
-
try {
|
|
1440
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1441
|
-
} catch {
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
for (const entry of entries) {
|
|
1445
|
-
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1446
|
-
const abs = path.join(dir, entry.name);
|
|
1447
|
-
if (entry.isDirectory()) {
|
|
1448
|
-
if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
|
|
1449
|
-
walk(abs, rel);
|
|
1450
|
-
continue;
|
|
1451
|
-
}
|
|
1452
|
-
if (!entry.isFile()) continue;
|
|
1453
|
-
if (/\.css$/i.test(entry.name)) css.push(rel);
|
|
1454
|
-
else if (/\.js$/i.test(entry.name)) js.push(rel);
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
walk(contentDir, '');
|
|
1458
|
-
css.sort();
|
|
1459
|
-
js.sort();
|
|
1460
|
-
|
|
1461
|
-
const publicCss: string[] = [];
|
|
1462
|
-
const publicJs: string[] = [];
|
|
1463
|
-
function walkPublic(dir: string, relBase: string) {
|
|
1464
|
-
let entries: fs.Dirent[];
|
|
1465
|
-
try {
|
|
1466
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1467
|
-
} catch {
|
|
1468
|
-
return;
|
|
1469
|
-
}
|
|
1470
|
-
for (const entry of entries) {
|
|
1471
|
-
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1472
|
-
const abs = path.join(dir, entry.name);
|
|
1473
|
-
if (entry.isDirectory()) {
|
|
1474
|
-
walkPublic(abs, rel);
|
|
1475
|
-
continue;
|
|
1476
|
-
}
|
|
1477
|
-
if (!entry.isFile()) continue;
|
|
1478
|
-
if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
|
|
1479
|
-
else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
walkPublic(path.join(contentDir, 'public'), '');
|
|
1483
|
-
publicCss.sort();
|
|
1484
|
-
publicJs.sort();
|
|
1485
|
-
|
|
1486
|
-
return { css, js, publicCss, publicJs };
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
/** The root-relative URL paths (leading `/`, e.g. `/images/hero.svg`)
|
|
1490
|
-
* referenced by the writedocs.json/styles fields that point at a static asset
|
|
1491
|
-
* *by URL* rather than embedding it inline: `styles.favicon`,
|
|
1492
|
-
* `styles.logo` (both the plain-string and `{light, dark}` object forms),
|
|
1493
|
-
* `styles.background.images.{light,dark}`, and `seo.ogImage`. External
|
|
1494
|
-
* URLs (anything not starting with `/` - `https://...`, mainly) are
|
|
1495
|
-
* filtered out, since those need no local file resolution at all.
|
|
1496
|
-
* Deduped, since e.g. `logo.light` and `background.images.light`
|
|
1497
|
-
* coincidentally pointing at the same file shouldn't resolve/copy it
|
|
1498
|
-
* twice.
|
|
1499
|
-
*
|
|
1500
|
-
* Used by `stylesAssetFallback()` (`styles-asset-integration.js`,
|
|
1501
|
-
* imported from `astro.config.mjs`) to let every one of these resolve
|
|
1502
|
-
* from *anywhere* in the project, not just `public/` - previously,
|
|
1503
|
-
* `public/` was the one place `styles.background.images` (etc.) had to
|
|
1504
|
-
* live, since Astro's own `publicDir` copy is the only thing that ever
|
|
1505
|
-
* served them; everywhere else in this codebase's own "drop a file
|
|
1506
|
-
* anywhere, it's found" convention (`findRootAssets()` right above,
|
|
1507
|
-
* `findAllPages()` for content) already worked project-wide. See that
|
|
1508
|
-
* integration's own comment for the actual resolution mechanism (a dev-time
|
|
1509
|
-
* middleware plus a post-build copy step, not a duplicated `publicDir`)
|
|
1510
|
-
* and why a straight copy-into-`public/`-on-disk approach was rejected.
|
|
1511
|
-
*
|
|
1512
|
-
* Logo light/dark resolution here deliberately mirrors BaseLayout.astro's
|
|
1513
|
-
* own `logoLight`/`logoDark` derivation exactly (a plain-string `logo`
|
|
1514
|
-
* counts as both light and dark) - two independent implementations of
|
|
1515
|
-
* that same union-unwrapping would only be one accidental edit away from
|
|
1516
|
-
* disagreeing with each other. `footer.logo` (footerSchema) gets the
|
|
1517
|
-
* same treatment, independently of `styles.logo` - both are collected
|
|
1518
|
-
* unconditionally here (not just whichever one BaseLayout.astro would
|
|
1519
|
-
* actually end up using for a given page), since this function has no
|
|
1520
|
-
* page context to know which page modes render a footer at all; an
|
|
1521
|
-
* unreferenced path collected here that never actually renders anywhere
|
|
1522
|
-
* is harmless (nothing copies/resolves a file that's never requested),
|
|
1523
|
-
* but a real footer.logo file silently 404ing because this function
|
|
1524
|
-
* didn't know to resolve it is exactly the bug this comment is warning
|
|
1525
|
-
* future edits away from repeating.
|
|
1526
|
-
*
|
|
1527
|
-
* Per-page frontmatter `seo.ogImage` overrides are deliberately out of
|
|
1528
|
-
* scope - those aren't visible from a `DocsConfig` alone (they live in
|
|
1529
|
-
* each page's own frontmatter, merged in per-request by [...slug].astro/
|
|
1530
|
-
* BaseLayout.astro), and resolving every page's own override would need
|
|
1531
|
-
* a full content scan this function has no reason to also become. A
|
|
1532
|
-
* page overriding `seo.ogImage` to something outside `public/` still
|
|
1533
|
-
* needs to put it there for now. */
|
|
1534
|
-
export function collectConfiguredAssetPaths(config: DocsConfig): string[] {
|
|
1535
|
-
const logo = config.styles.logo;
|
|
1536
|
-
const logoLight = typeof logo === 'string' ? logo : logo?.light;
|
|
1537
|
-
const logoDark = typeof logo === 'string' ? logo : logo?.dark;
|
|
1538
|
-
const footerLogo = config.footer.logo;
|
|
1539
|
-
const footerLogoLight = typeof footerLogo === 'string' ? footerLogo : footerLogo?.light;
|
|
1540
|
-
const footerLogoDark = typeof footerLogo === 'string' ? footerLogo : footerLogo?.dark;
|
|
1541
|
-
const fonts = config.styles.fonts;
|
|
1542
|
-
const raw: (string | undefined)[] = [
|
|
1543
|
-
config.styles.favicon,
|
|
1544
|
-
logoLight,
|
|
1545
|
-
logoDark,
|
|
1546
|
-
footerLogoLight,
|
|
1547
|
-
footerLogoDark,
|
|
1548
|
-
config.styles.background?.images?.light,
|
|
1549
|
-
config.styles.background?.images?.dark,
|
|
1550
|
-
config.seo?.ogImage,
|
|
1551
|
-
// A `styles.fonts` `source` is only ever handled here when it's a
|
|
1552
|
-
// project-relative path (the `p.startsWith('/')` filter below already
|
|
1553
|
-
// excludes both Google Font names, which never start with "/", and a
|
|
1554
|
-
// full https:// external font URL, which the browser fetches directly
|
|
1555
|
-
// - neither needs resolving/copying through this pipeline at all).
|
|
1556
|
-
fonts?.source,
|
|
1557
|
-
fonts?.heading?.source,
|
|
1558
|
-
fonts?.body?.source,
|
|
1559
|
-
];
|
|
1560
|
-
const paths = raw.filter((p): p is string => Boolean(p) && p.startsWith('/'));
|
|
1561
|
-
return Array.from(new Set(paths));
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
export interface DocsEntryLike {
|
|
1565
|
-
id: string;
|
|
1566
|
-
filePath?: string;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
/** Recovers a content entry's writedocs.json-facing file id (its path
|
|
1570
|
-
* relative to the content directory, extension stripped, trailing
|
|
1571
|
-
* `/index` dropped), independent of any frontmatter `slug` override -
|
|
1572
|
-
* `entry.filePath` is always root-relative and POSIX-separated (how
|
|
1573
|
-
* Astro's content layer records it, relative to whatever `--root` Astro
|
|
1574
|
-
* itself was invoked with - see run-astro.js), so both it and the
|
|
1575
|
-
* content directory are resolved to absolute paths before comparing,
|
|
1576
|
-
* rather than string-matching a prefix that could be relative,
|
|
1577
|
-
* absolute, or platform-separated inconsistently.
|
|
1578
|
-
*
|
|
1579
|
-
* The trailing-`/index`-drop mirrors Astro's own default id computation
|
|
1580
|
-
* exactly (`getContentEntryIdAndSlug()` in astro/dist/content/
|
|
1581
|
-
* utils.js: segments are joined then `.replace(/\/index$/, '')`) -
|
|
1582
|
-
* without it, a nested index file like `docs/guides/index.mdx` (Astro's
|
|
1583
|
-
* own default id: "docs/guides") would recover the wrong file id here
|
|
1584
|
-
* ("docs/guides/index"), and a writedocs.json reference written to match the
|
|
1585
|
-
* page's real URL would fail to resolve. A single-segment `index.mdx`
|
|
1586
|
-
* at the content root is unaffected either way - the regex requires a
|
|
1587
|
-
* preceding `/`, which a bare "index" doesn't have (matching Astro's
|
|
1588
|
-
* own behavior: a root-level index.mdx keeps file id "index", not "").
|
|
1589
|
-
*
|
|
1590
|
-
* Full per-segment slugification (github-slugger, applied by Astro to
|
|
1591
|
-
* every path segment) is deliberately *not* replicated here - every
|
|
1592
|
-
* filename in this codebase's own fixtures and every filename this
|
|
1593
|
-
* function needs to have handled correctly is already slug-safe
|
|
1594
|
-
* (lowercase, hyphenated, no spaces/unicode), so slugification is
|
|
1595
|
-
* always a no-op in practice; only the `/index`-stripping behavior is
|
|
1596
|
-
* reproduced, since that's the one part of Astro's algorithm this
|
|
1597
|
-
* change newly exercises (a file that used to be a collection's own
|
|
1598
|
-
* base-root index, exempt from stripping, and is now nested one level
|
|
1599
|
-
* deeper).
|
|
1600
|
-
*
|
|
1601
|
-
* Entries from the `generatedDocs` collection (auto-generated OpenAPI
|
|
1602
|
-
* stub pages - see content.config.ts / generate-api-pages.js) live
|
|
1603
|
-
* under writedocsTempDir()'s generated-docs/ directory - an OS temp
|
|
1604
|
-
* directory location entirely outside contentDir, not a subdirectory
|
|
1605
|
-
* of it - so `relativeToContentDir` for one of these always starts with
|
|
1606
|
-
* `..` (walking back out of contentDir to reach it), and the check
|
|
1607
|
-
* right below falls back to `entry.id` instead of computing a
|
|
1608
|
-
* nonsensical id relative to a directory the file was never actually
|
|
1609
|
-
* under. Those entries always set their own `slug` frontmatter
|
|
1610
|
-
* explicitly, so there's no independent "file path" identity worth
|
|
1611
|
-
* recovering for them the way there is for a hand-written page that
|
|
1612
|
-
* might move around - `entry.id` (Astro's already-resolved slug)
|
|
1613
|
-
* already IS the exact value generate-api-pages.js's own manifest
|
|
1614
|
-
* references them by. */
|
|
1615
|
-
export function fileIdForEntry(
|
|
1616
|
-
contentDir: string,
|
|
1617
|
-
packageRoot: string,
|
|
1618
|
-
entry: DocsEntryLike
|
|
1619
|
-
): string {
|
|
1620
|
-
if (!entry.filePath) return entry.id;
|
|
1621
|
-
const absoluteContentDir = path.resolve(contentDir);
|
|
1622
|
-
const absoluteFilePath = path.resolve(packageRoot, entry.filePath);
|
|
1623
|
-
const relativeToContentDir = path.relative(absoluteContentDir, absoluteFilePath);
|
|
1624
|
-
if (relativeToContentDir.startsWith('..')) return entry.id;
|
|
1625
|
-
const posixRelative = relativeToContentDir.split(path.sep).join('/');
|
|
1626
|
-
if (EXCLUDED_TOP_LEVEL_DIRS.has(posixRelative.split('/')[0])) return entry.id;
|
|
1627
|
-
return posixRelative.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
/** Normalizes a raw `entry.id` into the bare, slash-free form every
|
|
1631
|
-
* route/href in this codebase assumes. Once a page sets a frontmatter
|
|
1632
|
-
* `slug`, `entry.id` becomes that value completely verbatim - Astro's
|
|
1633
|
-
* glob loader applies no normalization of its own (see
|
|
1634
|
-
* generateIdDefault in astro/dist/content/loaders/glob.js) - so
|
|
1635
|
-
* `slug: /` or `slug: /guides/new-name` (both natural things to write,
|
|
1636
|
-
* mirroring how every href elsewhere in writedocs.json already has a
|
|
1637
|
-
* leading slash) would otherwise produce broken multi-slash hrefs
|
|
1638
|
-
* wherever hrefForSlug wraps the value in its own `/${slug}/`, and a
|
|
1639
|
-
* bare "/" would additionally fail to be recognized as claiming root,
|
|
1640
|
-
* colliding with the synthetic root-redirect route. */
|
|
1641
|
-
export function normalizeEntryId(id: string): string {
|
|
1642
|
-
const trimmed = id.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
1643
|
-
return trimmed === '' ? 'index' : trimmed;
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
export interface FlatNavEntry {
|
|
1647
|
-
slug: string;
|
|
1648
|
-
group: string | null;
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
export function flattenNav(navigation: NavItem[], group: string | null = null): FlatNavEntry[] {
|
|
1652
|
-
return navigation.flatMap((item): FlatNavEntry[] => {
|
|
1653
|
-
if (typeof item === 'string') {
|
|
1654
|
-
return [{ slug: item, group }];
|
|
1655
|
-
}
|
|
1656
|
-
if ('href' in item) return []; // external link leaf, not a content page - no route/prev-next entry
|
|
1657
|
-
// loadDocsConfig() always expands `{ group, openapi }` shorthand into
|
|
1658
|
-
// a real `{ group, pages }` before anything reaches here (see
|
|
1659
|
-
// expandOpenApiInNavigation() above) - this is just a defensive
|
|
1660
|
-
// no-op for the shouldn't-happen case of an unexpanded node.
|
|
1661
|
-
if ('openapi' in item) return [];
|
|
1662
|
-
const ownPage: FlatNavEntry[] = item.page ? [{ slug: item.page, group: item.group }] : [];
|
|
1663
|
-
return [...ownPage, ...flattenNav(item.pages, item.group)];
|
|
1664
|
-
});
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
// --- Sections -------------------------------------------------------
|
|
1668
|
-
//
|
|
1669
|
-
// A Section is the atomic unit that owns one page tree and therefore one
|
|
1670
|
-
// sidebar - every real content page belongs to exactly one Section,
|
|
1671
|
-
// whichever `pages` leaf it's listed under, however deep in the
|
|
1672
|
-
// tabs/versions/languages/dropdowns/products tree that leaf lives.
|
|
1673
|
-
//
|
|
1674
|
-
// A Section's `path` records the full chain of containers from the
|
|
1675
|
-
// navigation root down to it, one PathSegment per level. This is
|
|
1676
|
-
// everything the topbar needs to render the right selector controls
|
|
1677
|
-
// (tabs bar, version dropdown, ...) and highlight the right option in
|
|
1678
|
-
// each - without the router or layout needing to know how deep or in
|
|
1679
|
-
// what order the site author nested things.
|
|
1680
|
-
|
|
1681
|
-
export type PathSegment =
|
|
1682
|
-
| { kind: 'tab'; items: TabItem[]; index: number }
|
|
1683
|
-
| { kind: 'version'; items: VersionItem[]; index: number }
|
|
1684
|
-
| { kind: 'language'; items: LanguageItem[]; index: number }
|
|
1685
|
-
| { kind: 'dropdown'; items: DropdownItem[]; index: number }
|
|
1686
|
-
| { kind: 'product'; items: ProductItem[]; index: number };
|
|
1687
|
-
|
|
1688
|
-
export interface Section {
|
|
1689
|
-
pages: NavItem[];
|
|
1690
|
-
path: PathSegment[];
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
type Container = NavChildren;
|
|
1694
|
-
type NamedContainer = TabItem | VersionItem | LanguageItem | DropdownItem | ProductItem;
|
|
1695
|
-
|
|
1696
|
-
function walkSections(node: Container, path: PathSegment[], out: Section[]): void {
|
|
1697
|
-
if ('pages' in node) {
|
|
1698
|
-
out.push({ pages: node.pages, path });
|
|
1699
|
-
return;
|
|
1700
|
-
}
|
|
1701
|
-
if ('href' in node) return; // external link, not a Section
|
|
1702
|
-
if ('tabs' in node) {
|
|
1703
|
-
node.tabs.forEach((item, index) =>
|
|
1704
|
-
walkSections(item, [...path, { kind: 'tab', items: node.tabs, index }], out)
|
|
1705
|
-
);
|
|
1706
|
-
return;
|
|
1707
|
-
}
|
|
1708
|
-
if ('versions' in node) {
|
|
1709
|
-
node.versions.forEach((item, index) =>
|
|
1710
|
-
walkSections(item, [...path, { kind: 'version', items: node.versions, index }], out)
|
|
1711
|
-
);
|
|
1712
|
-
return;
|
|
1713
|
-
}
|
|
1714
|
-
if ('languages' in node) {
|
|
1715
|
-
node.languages.forEach((item, index) =>
|
|
1716
|
-
walkSections(item, [...path, { kind: 'language', items: node.languages, index }], out)
|
|
1717
|
-
);
|
|
1718
|
-
return;
|
|
1719
|
-
}
|
|
1720
|
-
if ('dropdowns' in node) {
|
|
1721
|
-
node.dropdowns.forEach((item, index) =>
|
|
1722
|
-
walkSections(item, [...path, { kind: 'dropdown', items: node.dropdowns, index }], out)
|
|
1723
|
-
);
|
|
1724
|
-
return;
|
|
1725
|
-
}
|
|
1726
|
-
if ('products' in node) {
|
|
1727
|
-
node.products.forEach((item, index) =>
|
|
1728
|
-
walkSections(item, [...path, { kind: 'product', items: node.products, index }], out)
|
|
1729
|
-
);
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
export function resolveSections(navigation: NavigationConfig): Section[] {
|
|
1735
|
-
if (Array.isArray(navigation)) return [{ pages: navigation, path: [] }];
|
|
1736
|
-
const sections: Section[] = [];
|
|
1737
|
-
walkSections(navigation as Container, [], sections);
|
|
1738
|
-
// global.dropdowns sits outside the primary pattern, but its pages
|
|
1739
|
-
// still need routes generated for them - walk each entry too, with an
|
|
1740
|
-
// empty path (they don't participate in the tabs/version/etc.
|
|
1741
|
-
// selector chain, only in the always-visible globalDropdowns list).
|
|
1742
|
-
for (const dropdown of navigation.global?.dropdowns ?? []) {
|
|
1743
|
-
walkSections(dropdown, [], sections);
|
|
1744
|
-
}
|
|
1745
|
-
return sections;
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
/** Which Section (by index into resolveSections()'s result) a page belongs to. */
|
|
1749
|
-
export function findSectionIndexForSlug(sections: Section[], slug: string): number {
|
|
1750
|
-
const index = sections.findIndex((s) => flattenNav(s.pages).some((entry) => entry.slug === slug));
|
|
1751
|
-
return index === -1 ? 0 : index;
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
/** The always-visible topbar dropdown list - independent of whichever
|
|
1755
|
-
* primary pattern/section is active (Mintlify's equivalent is
|
|
1756
|
-
* `navigation.global.anchors`). */
|
|
1757
|
-
export function resolveGlobalDropdowns(navigation: NavigationConfig): DropdownItem[] {
|
|
1758
|
-
if (Array.isArray(navigation)) return [];
|
|
1759
|
-
return navigation.global?.dropdowns ?? [];
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
/** The first real page slug reachable by descending into a container's
|
|
1763
|
-
* content, however deep - used to compute where a selector option (a
|
|
1764
|
-
* tab, a version, ...) navigates to when chosen. Null for an external
|
|
1765
|
-
* `href` leaf, which the caller renders as a plain link using the
|
|
1766
|
-
* node's own href instead. Versions prefer whichever entry is marked
|
|
1767
|
-
* `default` (falling back to the first), matching Mintlify's version
|
|
1768
|
-
* default rule. */
|
|
1769
|
-
/** Tries `firstSlugOf()` against each item in order, returning the first
|
|
1770
|
-
* non-null result - a plain `items[0]` pick (the previous behavior)
|
|
1771
|
-
* breaks the moment that first sibling happens to be a bare `href` leaf
|
|
1772
|
-
* (returns null, with nothing that tries the next one), which is a real
|
|
1773
|
-
* case now that any container - not just a nested dropdown - can be a
|
|
1774
|
-
* bare external link (e.g. a `tabs` array whose first entry is
|
|
1775
|
-
* `{ tab: "Status", href: "..." }`). */
|
|
1776
|
-
function firstSlugAmong(items: Container[]): string | null {
|
|
1777
|
-
for (const item of items) {
|
|
1778
|
-
const slug = firstSlugOf(item);
|
|
1779
|
-
if (slug !== null) return slug;
|
|
1780
|
-
}
|
|
1781
|
-
return null;
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
export function firstSlugOf(node: Container): string | null {
|
|
1785
|
-
if ('pages' in node) return flattenNav(node.pages)[0]?.slug ?? null;
|
|
1786
|
-
if ('href' in node) return null;
|
|
1787
|
-
if ('tabs' in node) return firstSlugAmong(node.tabs);
|
|
1788
|
-
if ('versions' in node) {
|
|
1789
|
-
// Try the `default`-tagged version(s) first (matching the old
|
|
1790
|
-
// "preferred" pick), then fall through to the rest in order - same
|
|
1791
|
-
// href-leaf-with-no-fallback concern as `tabs` above, just with an
|
|
1792
|
-
// extra preference pass in front of it.
|
|
1793
|
-
const defaults = node.versions.filter((v) => v.default);
|
|
1794
|
-
const rest = node.versions.filter((v) => !v.default);
|
|
1795
|
-
return firstSlugAmong([...defaults, ...rest]);
|
|
1796
|
-
}
|
|
1797
|
-
if ('languages' in node) return firstSlugAmong(node.languages);
|
|
1798
|
-
if ('dropdowns' in node) return firstSlugAmong(node.dropdowns);
|
|
1799
|
-
if ('products' in node) return firstSlugAmong(node.products);
|
|
1800
|
-
return null;
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
/** For a version/language switcher, finds where the reader's current
|
|
1804
|
-
* page would live inside a *different* version/language's own subtree,
|
|
1805
|
-
* so switching keeps them on the same conceptual page instead of always
|
|
1806
|
-
* bouncing to that option's first page (see buildSelectors() below,
|
|
1807
|
-
* which is the only caller). "Same page" is approximated positionally
|
|
1808
|
-
* rather than by name, since nothing guarantees a version/language's
|
|
1809
|
-
* own identifier lines up with its pages' file paths - a version
|
|
1810
|
-
* tagged "2025-09" could just as easily keep its pages under a "v1"
|
|
1811
|
-
* folder with no relation to that string, so there's no naming
|
|
1812
|
-
* convention to key off safely; this heuristic only ever compares tree
|
|
1813
|
-
* position, never path text. (docs.json-examples/00-kitchen-sink/'s own
|
|
1814
|
-
* versions - "2025-09"/"2026-01" under Core Platform - happen to have
|
|
1815
|
-
* folder names that line up with their version strings, so it doesn't
|
|
1816
|
-
* demonstrate the mismatched-folder-name case directly; the guarantee
|
|
1817
|
-
* still doesn't exist regardless of what one fixture happens to do.)
|
|
1818
|
-
*
|
|
1819
|
-
* Two things have to line up for a page to count as "the same" one:
|
|
1820
|
-
* first, `remainingPath` (whatever tab/product/dropdown was chosen
|
|
1821
|
-
* *below* the version/language being switched, on the reader's actual
|
|
1822
|
-
* page) has to exist at the same index in `item`'s own subtree too -
|
|
1823
|
-
* an option isn't guaranteed to nest the same way that many levels
|
|
1824
|
-
* down (a version could add/remove a tab), so any mismatch here bails
|
|
1825
|
-
* out to null immediately rather than guessing. Second, once both
|
|
1826
|
-
* bottom out at a leaf `pages` list, `position` (the reader's own page
|
|
1827
|
-
* index within *its* pages list) has to exist in `item`'s pages list
|
|
1828
|
-
* too - two versions/languages of the same docs are usually authored
|
|
1829
|
-
* with matching page order even when the file paths differ, so this
|
|
1830
|
-
* is a reasonable proxy for "the same page" without relying on names.
|
|
1831
|
-
*
|
|
1832
|
-
* Returns null - meaning "no equivalent page, fall back to
|
|
1833
|
-
* firstSlugOf()" - on any structural mismatch or an out-of-range
|
|
1834
|
-
* position, rather than guessing at a wrong page. */
|
|
1835
|
-
export function equivalentPageIn(
|
|
1836
|
-
item: Container,
|
|
1837
|
-
remainingPath: PathSegment[],
|
|
1838
|
-
position: number
|
|
1839
|
-
): string | null {
|
|
1840
|
-
if ('pages' in item) return flattenNav(item.pages)[position]?.slug ?? null;
|
|
1841
|
-
if ('href' in item) return null;
|
|
1842
|
-
const [next, ...rest] = remainingPath;
|
|
1843
|
-
if (!next) return null; // the reader's own page didn't go this deep - no basis to pick a branch
|
|
1844
|
-
if ('tabs' in item && next.kind === 'tab') {
|
|
1845
|
-
const target = item.tabs[next.index];
|
|
1846
|
-
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1847
|
-
}
|
|
1848
|
-
if ('versions' in item && next.kind === 'version') {
|
|
1849
|
-
const target = item.versions[next.index];
|
|
1850
|
-
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1851
|
-
}
|
|
1852
|
-
if ('languages' in item && next.kind === 'language') {
|
|
1853
|
-
const target = item.languages[next.index];
|
|
1854
|
-
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1855
|
-
}
|
|
1856
|
-
if ('dropdowns' in item && next.kind === 'dropdown') {
|
|
1857
|
-
const target = item.dropdowns[next.index];
|
|
1858
|
-
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1859
|
-
}
|
|
1860
|
-
if ('products' in item && next.kind === 'product') {
|
|
1861
|
-
const target = item.products[next.index];
|
|
1862
|
-
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1863
|
-
}
|
|
1864
|
-
return null; // structural mismatch - this option nests differently at this depth
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
/** The first real page slug in the whole site, root navigation pattern
|
|
1868
|
-
* included - used to redirect `/` somewhere sensible when nothing in
|
|
1869
|
-
* the navigation happens to be a page literally named "index" (the
|
|
1870
|
-
* usual "docs/index.mdx is the homepage" convention). Mirrors
|
|
1871
|
-
* firstSlugOf()'s per-container descent, plus the flat-array root case
|
|
1872
|
-
* firstSlugOf() alone can't handle since a bare array isn't a Container. */
|
|
1873
|
-
export function firstSlugOfNavigation(navigation: NavigationConfig): string | null {
|
|
1874
|
-
if (Array.isArray(navigation)) return flattenNav(navigation)[0]?.slug ?? null;
|
|
1875
|
-
return firstSlugOf(navigation as Container);
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
/** Whether `slug` is reachable anywhere underneath a container, however
|
|
1879
|
-
* deep - used for computing active state on nodes that aren't part of
|
|
1880
|
-
* the active Section's own `path` (global dropdown entries). */
|
|
1881
|
-
export function containerContainsSlug(node: Container, slug: string): boolean {
|
|
1882
|
-
if ('pages' in node) return flattenNav(node.pages).some((entry) => entry.slug === slug);
|
|
1883
|
-
if ('href' in node) return false;
|
|
1884
|
-
if ('tabs' in node) return node.tabs.some((t) => containerContainsSlug(t, slug));
|
|
1885
|
-
if ('versions' in node) return node.versions.some((v) => containerContainsSlug(v, slug));
|
|
1886
|
-
if ('languages' in node) return node.languages.some((l) => containerContainsSlug(l, slug));
|
|
1887
|
-
if ('dropdowns' in node) return node.dropdowns.some((d) => containerContainsSlug(d, slug));
|
|
1888
|
-
if ('products' in node) return node.products.some((p) => containerContainsSlug(p, slug));
|
|
1889
|
-
return false;
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
function labelOf(item: NamedContainer): string {
|
|
1893
|
-
if ('tab' in item) return item.tab;
|
|
1894
|
-
if ('version' in item) return item.label ?? item.version;
|
|
1895
|
-
if ('language' in item) return item.label ?? item.language;
|
|
1896
|
-
if ('dropdown' in item) return item.dropdown;
|
|
1897
|
-
return item.product;
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
function iconOf(item: NamedContainer): string | undefined {
|
|
1901
|
-
return (item as { icon?: string }).icon;
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
// --- Topbar selectors -------------------------------------------------
|
|
1905
|
-
//
|
|
1906
|
-
// One Selector per PathSegment on the active Section's path. Tabs render
|
|
1907
|
-
// as the horizontal pill bar (all options always visible, exactly one
|
|
1908
|
-
// current); versions/languages/products, and dropdowns used as a nested
|
|
1909
|
-
// path segment, render as a single switcher control instead - the
|
|
1910
|
-
// trigger shows the *current* option, its menu lists the alternatives.
|
|
1911
|
-
// See BaseLayout.astro for the actual markup per kind.
|
|
1912
|
-
|
|
1913
|
-
export interface SelectorOption {
|
|
1914
|
-
label: string;
|
|
1915
|
-
icon?: string;
|
|
1916
|
-
tag?: string;
|
|
1917
|
-
href: string;
|
|
1918
|
-
active: boolean;
|
|
1919
|
-
// Set when this option's own container is a `dropdowns` list (e.g. a
|
|
1920
|
-
// tab whose content is `dropdowns` instead of `pages`) - the tab pill
|
|
1921
|
-
// itself becomes a dropdown-trigger showing these as its menu, rather
|
|
1922
|
-
// than a separate dropdown control rendered alongside it. Only ever
|
|
1923
|
-
// populated one level deep (a dropdown option doesn't itself get a
|
|
1924
|
-
// nested `dropdown` - matches the one level of "a tab/dropdown owns a
|
|
1925
|
-
// dropdowns list" this is meant to cover).
|
|
1926
|
-
dropdown?: SelectorOption[];
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
export interface Selector {
|
|
1930
|
-
kind: PathSegment['kind'];
|
|
1931
|
-
options: SelectorOption[];
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
/** An option's own `dropdowns` menu, if it has one (see SelectorOption.dropdown
|
|
1935
|
-
* above) - `activeSegment` is the *next* PathSegment after this option's own
|
|
1936
|
-
* segment, only passed when this option is the active one on its level, so a
|
|
1937
|
-
* sibling option that also happens to own `dropdowns` doesn't spuriously mark
|
|
1938
|
-
* one of its entries active just because some *other* option is currently
|
|
1939
|
-
* selected. */
|
|
1940
|
-
function dropdownMenuOf(
|
|
1941
|
-
node: NavChildren,
|
|
1942
|
-
hrefForSlug: (slug: string) => string,
|
|
1943
|
-
activeSegment: PathSegment | undefined
|
|
1944
|
-
): SelectorOption[] | undefined {
|
|
1945
|
-
if (!('dropdowns' in node)) return undefined;
|
|
1946
|
-
return node.dropdowns.map((d, i) => ({
|
|
1947
|
-
label: d.dropdown,
|
|
1948
|
-
icon: iconOf(d),
|
|
1949
|
-
href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
|
|
1950
|
-
active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
|
|
1951
|
-
}));
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
/** `activePagePosition` is the reader's current page's own index within
|
|
1955
|
-
* its Section's flattened pages list (-1 if it can't be found there,
|
|
1956
|
-
* which just disables the position-preserving behavior below) - see
|
|
1957
|
-
* [...slug].astro for how it's computed. Only version/language
|
|
1958
|
-
* switchers try to preserve position across options; tabs/dropdowns/
|
|
1959
|
-
* products keep linking to firstSlugOf() unconditionally, since those
|
|
1960
|
-
* represent genuinely different content (an "API Reference" tab isn't
|
|
1961
|
-
* "the same page" as a "Guides" tab just because they're both first),
|
|
1962
|
-
* unlike a version/language of what's meant to be the same docs. */
|
|
1963
|
-
export function buildSelectors(
|
|
1964
|
-
path: PathSegment[],
|
|
1965
|
-
hrefForSlug: (slug: string) => string,
|
|
1966
|
-
activePagePosition: number = -1
|
|
1967
|
-
): Selector[] {
|
|
1968
|
-
return path.map((segment, segIndex): Selector => {
|
|
1969
|
-
const preservesPosition =
|
|
1970
|
-
(segment.kind === 'version' || segment.kind === 'language') && activePagePosition >= 0;
|
|
1971
|
-
const remainingPath = path.slice(segIndex + 1);
|
|
1972
|
-
return {
|
|
1973
|
-
kind: segment.kind,
|
|
1974
|
-
options: (segment.items as NamedContainer[]).map((item, i) => {
|
|
1975
|
-
const isActive = i === segment.index;
|
|
1976
|
-
const equivalentSlug = preservesPosition
|
|
1977
|
-
? equivalentPageIn(item as Container, remainingPath, activePagePosition)
|
|
1978
|
-
: null;
|
|
1979
|
-
const targetSlug = equivalentSlug ?? firstSlugOf(item as Container) ?? 'index';
|
|
1980
|
-
return {
|
|
1981
|
-
label: labelOf(item),
|
|
1982
|
-
icon: iconOf(item),
|
|
1983
|
-
tag: 'tag' in item ? item.tag : undefined,
|
|
1984
|
-
href: 'href' in item ? item.href : hrefForSlug(targetSlug),
|
|
1985
|
-
active: isActive,
|
|
1986
|
-
dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
|
|
1987
|
-
};
|
|
1988
|
-
}),
|
|
1989
|
-
};
|
|
1990
|
-
});
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
// --- Global dropdowns ---------------------------------------------------
|
|
1994
|
-
//
|
|
1995
|
-
// Unlike path-segment selectors, global dropdowns aren't a mutually
|
|
1996
|
-
// exclusive "pick one" choice - `global.dropdowns` is an array where
|
|
1997
|
-
// *every* entry renders as its own always-visible trigger button
|
|
1998
|
-
// simultaneously (Mintlify's anchors work the same way). A trigger's
|
|
1999
|
-
// menu lists its own direct pages as quick links; a bare-href entry is
|
|
2000
|
-
// just a plain link with no menu; a deeply-nested entry (tabs/versions/
|
|
2001
|
-
// etc. instead of direct pages) falls back to linking straight to its
|
|
2002
|
-
// first page, since building a rich flyout for that combination isn't
|
|
2003
|
-
// worth the complexity for what's meant to be a quick-links affordance.
|
|
2004
|
-
|
|
2005
|
-
export interface GlobalDropdownView {
|
|
2006
|
-
label: string;
|
|
2007
|
-
icon?: string;
|
|
2008
|
-
href: string | null;
|
|
2009
|
-
items: SelectorOption[];
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
export function buildGlobalDropdowns(
|
|
2013
|
-
dropdowns: DropdownItem[],
|
|
2014
|
-
currentSlug: string,
|
|
2015
|
-
titleForSlug: (slug: string) => string,
|
|
2016
|
-
hrefForSlug: (slug: string) => string
|
|
2017
|
-
): GlobalDropdownView[] {
|
|
2018
|
-
return dropdowns.map((d): GlobalDropdownView => {
|
|
2019
|
-
if ('href' in d) {
|
|
2020
|
-
return { label: d.dropdown, icon: d.icon, href: d.href, items: [] };
|
|
2021
|
-
}
|
|
2022
|
-
if ('pages' in d) {
|
|
2023
|
-
const items = flattenNav(d.pages).map((entry) => ({
|
|
2024
|
-
label: titleForSlug(entry.slug),
|
|
2025
|
-
href: hrefForSlug(entry.slug),
|
|
2026
|
-
active: entry.slug === currentSlug,
|
|
2027
|
-
}));
|
|
2028
|
-
return { label: d.dropdown, icon: d.icon, href: null, items };
|
|
2029
|
-
}
|
|
2030
|
-
const slug = firstSlugOf(d);
|
|
2031
|
-
return { label: d.dropdown, icon: d.icon, href: slug ? hrefForSlug(slug) : '#', items: [] };
|
|
2032
|
-
});
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
// --- Sidebar (recursive) -------------------------------------------------
|
|
2036
|
-
|
|
2037
|
-
// A group node's `pageSlug` is the page its own label links to (null if
|
|
2038
|
-
// it's a pure disclosure/label with no page of its own - the group only
|
|
2039
|
-
// groups). NavTree.astro renders depth-0 groups as static, non-collapsible
|
|
2040
|
-
// section titles (linked if `pageSlug` is set, plain text otherwise) and
|
|
2041
|
-
// every deeper group as a collapsible row styled like a page item, with a
|
|
2042
|
-
// chevron, defaulting open when it contains the active page - see
|
|
2043
|
-
// navTreeContainsSlug() below.
|
|
2044
|
-
export type NavTreeNode =
|
|
2045
|
-
| { kind: 'page'; slug: string; title: string; method: string | null }
|
|
2046
|
-
| { kind: 'group'; label: string; pageSlug: string | null; children: NavTreeNode[] }
|
|
2047
|
-
| { kind: 'link'; label: string; href: string };
|
|
2048
|
-
|
|
2049
|
-
/** Builds the sidebar's view model, preserving group nesting depth.
|
|
2050
|
-
* `methodForSlug` is optional (most sites have no OpenAPI pages at all)
|
|
2051
|
-
* and, when given, returns the HTTP method to badge a page with in the
|
|
2052
|
-
* sidebar (e.g. "GET") or null for an ordinary page - see
|
|
2053
|
-
* NavTree.astro for how that badge renders. */
|
|
2054
|
-
export function buildNavTree(
|
|
2055
|
-
navigation: NavItem[],
|
|
2056
|
-
titleForSlug: (slug: string) => string,
|
|
2057
|
-
methodForSlug?: (slug: string) => string | null
|
|
2058
|
-
): NavTreeNode[] {
|
|
2059
|
-
return navigation.map((item): NavTreeNode => {
|
|
2060
|
-
if (typeof item === 'string') {
|
|
2061
|
-
return {
|
|
2062
|
-
kind: 'page',
|
|
2063
|
-
slug: item,
|
|
2064
|
-
title: titleForSlug(item),
|
|
2065
|
-
method: methodForSlug?.(item) ?? null,
|
|
2066
|
-
};
|
|
2067
|
-
}
|
|
2068
|
-
if ('href' in item) {
|
|
2069
|
-
return { kind: 'link', label: item.label, href: item.href };
|
|
2070
|
-
}
|
|
2071
|
-
// loadDocsConfig() always expands `{ group, openapi }` shorthand into
|
|
2072
|
-
// a real `{ group, pages }` before anything reaches here (see
|
|
2073
|
-
// expandOpenApiInNavigation() in the OpenAPI section above) - this is
|
|
2074
|
-
// just a defensive no-op for the shouldn't-happen case of an
|
|
2075
|
-
// unexpanded node reaching the sidebar builder.
|
|
2076
|
-
if ('openapi' in item) {
|
|
2077
|
-
return { kind: 'group', label: item.group, pageSlug: null, children: [] };
|
|
2078
|
-
}
|
|
2079
|
-
return {
|
|
2080
|
-
kind: 'group',
|
|
2081
|
-
label: item.group,
|
|
2082
|
-
pageSlug: item.page ?? null,
|
|
2083
|
-
children: buildNavTree(item.pages, titleForSlug, methodForSlug),
|
|
2084
|
-
};
|
|
2085
|
-
});
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
/** Whether `slug` is the group's own attached page, or belongs to any page/group nested inside it. */
|
|
2089
|
-
export function navTreeContainsSlug(nodes: NavTreeNode[], slug: string): boolean {
|
|
2090
|
-
return nodes.some((node) => {
|
|
2091
|
-
if (node.kind === 'page') return node.slug === slug;
|
|
2092
|
-
if (node.kind === 'link') return false;
|
|
2093
|
-
return node.pageSlug === slug || navTreeContainsSlug(node.children, slug);
|
|
2094
|
-
});
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
export interface BreadcrumbCrumb {
|
|
2098
|
-
label: string;
|
|
2099
|
-
href: string | null;
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
/** The chain of ancestor group labels (root first) that `slug` is nested
|
|
2103
|
-
* under within `nodes` - empty when the page sits at the top level of its
|
|
2104
|
-
* Section with no enclosing group at all, in which case the caller should
|
|
2105
|
-
* skip rendering breadcrumbs entirely (there'd be nothing to show but the
|
|
2106
|
-
* fixed home icon Breadcrumbs.astro always renders first).
|
|
2107
|
-
*
|
|
2108
|
-
* Deliberately never includes the current page itself - only the groups
|
|
2109
|
-
* it's nested under (that's already the <h1> right below, repeating it
|
|
2110
|
-
* in the breadcrumb trail would just be noise). A group whose own
|
|
2111
|
-
* attached page (`pageSlug`, see NavTreeNode) *is* `slug` is excluded
|
|
2112
|
-
* from its own trail for the same reason - you're looking at that page,
|
|
2113
|
-
* it doesn't need to also list itself as its own ancestor. A group only
|
|
2114
|
-
* appears here when `slug` is nested *inside* it (its own page, if any,
|
|
2115
|
-
* links to that group's landing page - `href: null` for a label-only
|
|
2116
|
-
* group with no page of its own to link to). */
|
|
2117
|
-
export function ancestorGroupsForSlug(
|
|
2118
|
-
nodes: NavTreeNode[],
|
|
2119
|
-
slug: string,
|
|
2120
|
-
hrefForSlug: (slug: string) => string
|
|
2121
|
-
): BreadcrumbCrumb[] {
|
|
2122
|
-
for (const node of nodes) {
|
|
2123
|
-
if (node.kind !== 'group') continue;
|
|
2124
|
-
if (node.pageSlug === slug) return [];
|
|
2125
|
-
if (navTreeContainsSlug(node.children, slug)) {
|
|
2126
|
-
const crumb: BreadcrumbCrumb = { label: node.label, href: node.pageSlug ? hrefForSlug(node.pageSlug) : null };
|
|
2127
|
-
return [crumb, ...ancestorGroupsForSlug(node.children, slug, hrefForSlug)];
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
return [];
|
|
2131
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
5
|
+
import {
|
|
6
|
+
formatValidationIssues,
|
|
7
|
+
validateDocsConfig,
|
|
8
|
+
type DocsConfig,
|
|
9
|
+
type DropdownItem,
|
|
10
|
+
type FontVariant,
|
|
11
|
+
type LanguageItem,
|
|
12
|
+
type NavChildren,
|
|
13
|
+
type NavItem,
|
|
14
|
+
type NavigationConfig,
|
|
15
|
+
type ProductItem,
|
|
16
|
+
type TabItem,
|
|
17
|
+
type VersionItem,
|
|
18
|
+
} from './config-schema.ts';
|
|
19
|
+
|
|
20
|
+
// O schema do writedocs.json mora em ./config-schema.ts desde a extracao (E11):
|
|
21
|
+
// aquele modulo importa so `zod`, sem nada de node:fs, pra que a plataforma
|
|
22
|
+
// possa importa-lo por `@writedocs/generator/config-schema`. Este reexport
|
|
23
|
+
// mantem a superficie deste arquivo exatamente como era - quem importa
|
|
24
|
+
// `docsConfigSchema`, `DocsConfig`, `seoFieldsSchema`, `mergeSeo` etc. de
|
|
25
|
+
// './config.js' continua importando do mesmo lugar, sem mudar uma linha.
|
|
26
|
+
//
|
|
27
|
+
// `.ts` e nao o `./config-schema.js` gerado, de proposito - e a unica coisa no
|
|
28
|
+
// repositorio que ainda aponta pra fonte, e o bin/ e o `exports` apontam pro
|
|
29
|
+
// `.js`. O motivo e que este reexport carrega 24 `export type`/`interface`
|
|
30
|
+
// (DocsConfig, NavItem, Selector, GlobalDropdownView, ContextMenuConfig...) que
|
|
31
|
+
// uma duzia de .astro consome como `import type { X } from '../lib/config'`, e
|
|
32
|
+
// o esbuild apaga todos eles: o `.js` gerado exporta exatamente 5 simbolos de
|
|
33
|
+
// runtime. Este repositorio nao tem tsconfig.json nem o typescript instalado,
|
|
34
|
+
// entao (a) nada configura o mapeamento `.js` -> `.ts` que faria os tipos
|
|
35
|
+
// voltarem e (b) nao existe typecheck que reclamasse - a troca so apagaria os
|
|
36
|
+
// tipos em silencio. Aqui tudo passa pelo transform do Astro/Vite, que le `.ts`
|
|
37
|
+
// nativamente, entao o `.js` nao resolve problema nenhum deste lado.
|
|
38
|
+
//
|
|
39
|
+
// O custo aceito e que o tarball leva as duas copias e um build do Astro pode
|
|
40
|
+
// carregar as duas (o `.ts` por aqui, o `.js` por quem importa o subpath) - duas
|
|
41
|
+
// instancias do schema, inofensivas porque nada compara identidade de schema.
|
|
42
|
+
export * from './config-schema.ts';
|
|
43
|
+
|
|
44
|
+
/** Normalizes writedocs.json's `domain` into a full origin with no trailing
|
|
45
|
+
* slash (e.g. "docs.example.com" -> "https://docs.example.com"), or null
|
|
46
|
+
* if the site hasn't set one. The single source of truth for "does this
|
|
47
|
+
* site have a real deployed URL" - astro.config.mjs (sitemap
|
|
48
|
+
* registration), BaseLayout.astro (canonical/OG/Twitter URLs), and
|
|
49
|
+
* resolveAbsoluteUrl() below all call this rather than reading
|
|
50
|
+
* config.domain directly, so the http(s):// normalization only happens
|
|
51
|
+
* in one place. */
|
|
52
|
+
export function resolveSiteUrl(config: Pick<DocsConfig, 'domain'>): string | null {
|
|
53
|
+
if (!config.domain) return null;
|
|
54
|
+
const withScheme = /^https?:\/\//i.test(config.domain) ? config.domain : `https://${config.domain}`;
|
|
55
|
+
return withScheme.replace(/\/+$/, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolves a possibly-relative asset path (e.g. `seo.ogImage: "/card.png"`)
|
|
59
|
+
* against the site's own domain into an absolute URL - social crawlers
|
|
60
|
+
* (Facebook/Twitter/Slack unfurls) generally require an absolute
|
|
61
|
+
* og:image/twitter:image URL, a same-origin relative path isn't reliably
|
|
62
|
+
* respected. Returns the value unchanged if it's already absolute, or if
|
|
63
|
+
* there's no siteUrl to resolve it against (a relative path is still
|
|
64
|
+
* better than nothing in that case - most social crawlers do at least
|
|
65
|
+
* attempt to fetch it relative to the page they scraped). */
|
|
66
|
+
export function resolveAbsoluteUrl(siteUrl: string | null, value: string): string {
|
|
67
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
68
|
+
if (!siteUrl) return value;
|
|
69
|
+
return `${siteUrl}${value.startsWith('/') ? '' : '/'}${value}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The resolved (never-undefined) Shiki theme names for light/dark code
|
|
73
|
+
* blocks - shared by astro.config.mjs (sitewide MDX code-fence
|
|
74
|
+
* highlighting) and ApiReferencePanel.astro (the API playground's own
|
|
75
|
+
* separate <Code/> usages, which don't inherit markdown.shikiConfig at
|
|
76
|
+
* all - see astro.config.mjs's own comment on that) so both read the
|
|
77
|
+
* same writedocs.json field and fall back to the same defaults instead of
|
|
78
|
+
* each hardcoding its own copy. */
|
|
79
|
+
export function resolveCodeblockTheme(config: DocsConfig): { light: string; dark: string } {
|
|
80
|
+
return {
|
|
81
|
+
light: config.styles.codeblocks?.light ?? 'github-light',
|
|
82
|
+
dark: config.styles.codeblocks?.dark ?? 'github-dark',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// mdx is a real, bundled Shiki grammar (@shikijs/langs' mdx.mjs - it does
|
|
87
|
+
// exist, this isn't a "Shiki doesn't know this language" situation), but in
|
|
88
|
+
// practice it tokenizes ```mdx fences into one single run per line with no
|
|
89
|
+
// internal token boundaries at all - every character, JSX tag or not,
|
|
90
|
+
// lands in the exact same TextMate scope and renders in the theme's plain
|
|
91
|
+
// foreground color. Confirmed directly against real built output: every
|
|
92
|
+
// span in a ```mdx block's compiled HTML carries the identical inline
|
|
93
|
+
// color, none of the tag/attribute/string distinction a JS or JSX fence
|
|
94
|
+
// gets. Aliasing to jsx - a close structural match for the JSX-heavy
|
|
95
|
+
// snippets these fences are actually used for in this codebase's own docs
|
|
96
|
+
// (`<Callout>`, `<Card>`, etc.) - actually highlights the tags/attributes,
|
|
97
|
+
// at the cost of not distinctly coloring the markdown-prose portions
|
|
98
|
+
// interleaved between them (jsx's grammar doesn't know about those) - a
|
|
99
|
+
// worthwhile trade given the alternative is no color at all. See
|
|
100
|
+
// astro.config.mjs's own shikiConfig.langAlias for where this actually
|
|
101
|
+
// gets used. */
|
|
102
|
+
const DEFAULT_CODEBLOCK_LANG_ALIAS: Record<string, string> = { mdx: 'jsx' };
|
|
103
|
+
|
|
104
|
+
/** The resolved fence-language alias map - the built-in mdx -> jsx default
|
|
105
|
+
* above, merged with (not replaced by) whatever a site adds under its own
|
|
106
|
+
* writedocs.json styles.codeblocks.langAlias, so a site can extend this list
|
|
107
|
+
* without having to redeclare the built-in entry to keep it. Same
|
|
108
|
+
* "shared resolver, not each consumer re-deriving its own defaults"
|
|
109
|
+
* pattern resolveCodeblockTheme() right above already establishes -
|
|
110
|
+
* though today only astro.config.mjs actually consumes this one, unlike
|
|
111
|
+
* that one, since ApiReferencePanel.astro's own <Code/> usages never
|
|
112
|
+
* render a `mdx`-tagged snippet (API operation samples are curl/js/
|
|
113
|
+
* python/etc., not MDX markup) to begin with. */
|
|
114
|
+
export function resolveCodeblockLangAlias(config: DocsConfig): Record<string, string> {
|
|
115
|
+
return { ...DEFAULT_CODEBLOCK_LANG_ALIAS, ...config.styles.codeblocks?.langAlias };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const DEFAULT_FONT_FAMILY = 'Inter';
|
|
119
|
+
|
|
120
|
+
export interface ResolvedFonts {
|
|
121
|
+
base: FontVariant;
|
|
122
|
+
heading: FontVariant | null;
|
|
123
|
+
body: FontVariant | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolves writedocs.json's `styles.fonts` into the three font declarations
|
|
127
|
+
* BaseLayout.astro actually needs to render: `base` (the site-wide
|
|
128
|
+
* default - every element gets this unless `heading`/`body` narrows it
|
|
129
|
+
* further), and `heading`/`body`, each `null` when not independently
|
|
130
|
+
* configured (letting BaseLayout fall back to `base` for whichever side
|
|
131
|
+
* wasn't overridden, rather than this function silently copying `base`
|
|
132
|
+
* into both and losing the distinction between "explicitly set to the
|
|
133
|
+
* same font" and "just inheriting the default"). The one hardcoded
|
|
134
|
+
* default in this whole feature lives right here: no `styles.fonts` at
|
|
135
|
+
* all resolves to plain Inter, loaded for real (a Google Fonts `<link>`,
|
|
136
|
+
* not just a name in a fallback stack that only renders correctly for a
|
|
137
|
+
* reader who happens to already have Inter installed - see base.css's
|
|
138
|
+
* old `font-family` rule, which was exactly that, before this feature
|
|
139
|
+
* existed). */
|
|
140
|
+
export function resolveFonts(config: DocsConfig): ResolvedFonts {
|
|
141
|
+
const fonts = config.styles.fonts;
|
|
142
|
+
const base: FontVariant = fonts
|
|
143
|
+
? { family: fonts.family, weight: fonts.weight, source: fonts.source, format: fonts.format }
|
|
144
|
+
: { family: DEFAULT_FONT_FAMILY };
|
|
145
|
+
return { base, heading: fonts?.heading ?? null, body: fonts?.body ?? null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The Google Fonts CSS2 API URL for every font in `fonts` that isn't a
|
|
149
|
+
* `source`-based (local/externally-hosted) font - `null` if there's
|
|
150
|
+
* nothing to load this way at all (every configured font has its own
|
|
151
|
+
* `source`). One request covers every family needed (`&family=` repeated
|
|
152
|
+
* per unique family+weight pair, deduped so the same pair - e.g. `base`
|
|
153
|
+
* and `body` both left at the site default - isn't requested twice)
|
|
154
|
+
* rather than a separate `<link>` per font. `display=swap` avoids an
|
|
155
|
+
* invisible-text flash while the font file loads (renders in the
|
|
156
|
+
* fallback stack immediately, swaps once the real font is ready) -
|
|
157
|
+
* Google's own recommended default for exactly this use case. */
|
|
158
|
+
export function googleFontsHref(fonts: ResolvedFonts): string | null {
|
|
159
|
+
const entries = [fonts.base, fonts.heading, fonts.body].filter(
|
|
160
|
+
(f): f is FontVariant => f !== null && !f.source
|
|
161
|
+
);
|
|
162
|
+
if (entries.length === 0) return null;
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
const params: string[] = [];
|
|
165
|
+
for (const f of entries) {
|
|
166
|
+
const key = `${f.family}|${f.weight ?? ''}`;
|
|
167
|
+
if (seen.has(key)) continue;
|
|
168
|
+
seen.add(key);
|
|
169
|
+
const familyParam = f.family.trim().replace(/\s+/g, '+');
|
|
170
|
+
params.push(f.weight ? `family=${familyParam}:wght@${f.weight}` : `family=${familyParam}`);
|
|
171
|
+
}
|
|
172
|
+
return `https://fonts.googleapis.com/css2?${params.join('&')}&display=swap`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A `@font-face` rule for one `source`-based font (local project path or
|
|
176
|
+
* externally-hosted URL), or `''` for a Google Font (no `source` - see
|
|
177
|
+
* googleFontsHref() above, the other half of font loading). `weight`
|
|
178
|
+
* becomes the rule's `font-weight` *descriptor* here - it tells the
|
|
179
|
+
* browser which weight this specific file represents, so a `font-weight`
|
|
180
|
+
* CSS value requested elsewhere (BaseLayout.astro's own
|
|
181
|
+
* `wdFontWeightHeading`/`wdFontWeightBody`, see its comment) picks the
|
|
182
|
+
* real matching file instead of synthetically ("faux") bolding/
|
|
183
|
+
* thinning a mismatched one. This function only ever produces the
|
|
184
|
+
* `@font-face` rule itself - actually applying `font-weight` to any
|
|
185
|
+
* element (h1-h6, body) is BaseLayout's job, not this one's. */
|
|
186
|
+
export function fontFaceRule(font: FontVariant | null): string {
|
|
187
|
+
if (!font || !font.source) return '';
|
|
188
|
+
const weightDecl = font.weight !== undefined ? ` font-weight: ${font.weight};` : '';
|
|
189
|
+
return `@font-face { font-family: '${font.family}'; src: url('${font.source}') format('${font.format ?? 'woff2'}'); font-display: swap;${weightDecl} }`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Splits one side of `styles.navbar` (the string-or-object union
|
|
193
|
+
* navbarColorValueSchema allows) into its two parts, filling in the
|
|
194
|
+
* fallback background when the field is unset at all. `accent` stays
|
|
195
|
+
* `undefined` - not defaulted here - for both the bare-string case and
|
|
196
|
+
* the object case where a site set `background` without `accent`;
|
|
197
|
+
* BaseLayout.astro is what turns that `undefined` into "fall back to
|
|
198
|
+
* --wd-primary" for the accent CSS var. There's no `foreground` here at
|
|
199
|
+
* all to resolve - the navbar's text/icon color is never read from
|
|
200
|
+
* writedocs.json, see `navbar`'s own schema comment (stylesSchema) for why. */
|
|
201
|
+
export function resolveNavbarColor(
|
|
202
|
+
value: string | { background: string; accent?: string } | undefined,
|
|
203
|
+
fallbackBackground: string
|
|
204
|
+
): { background: string; accent: string | undefined } {
|
|
205
|
+
if (!value) return { background: fallbackBackground, accent: undefined };
|
|
206
|
+
if (typeof value === 'string') return { background: value, accent: undefined };
|
|
207
|
+
return { background: value.background, accent: value.accent };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Picks black or white text for readable contrast against `hexColor`,
|
|
211
|
+
* via the standard relative-luminance formula (ITU-R BT.601 weights -
|
|
212
|
+
* the same "perceived brightness" approximation used all over the web
|
|
213
|
+
* for exactly this "what text color goes on this swatch" problem, not
|
|
214
|
+
* the more expensive WCAG relative-luminance formula, which isn't
|
|
215
|
+
* needed for a binary choose-the-less-bad-option decision like this
|
|
216
|
+
* one). Used two ways in BaseLayout.astro: for `--wd-navbar-accent-text`
|
|
217
|
+
* (the active tab's own fill used to always be `var(--wd-primary)` with
|
|
218
|
+
* hardcoded `color: #fff`, which only actually read fine because every
|
|
219
|
+
* default/example primary color so far has been dark/saturated enough
|
|
220
|
+
* for white text; once a site's navbar accent can be *any* color -
|
|
221
|
+
* `styles.navbar.light.accent`, falling back to `styles.colors.primary`
|
|
222
|
+
* when unset, see resolveNavbarColor() above - that assumption can't
|
|
223
|
+
* hold unconditionally), and for `--wd-navbar-foreground` itself once
|
|
224
|
+
* `styles.navbar` is configured at all (the navbar's plain text/icon
|
|
225
|
+
* color - see `navbar`'s own schema comment for why that's always
|
|
226
|
+
* computed, never a writedocs.json value). Malformed input (not a 6-digit
|
|
227
|
+
* `#rrggbb` hex) falls back to white rather than throwing - same "don't
|
|
228
|
+
* fail a build over a cosmetic color value" posture every other color
|
|
229
|
+
* field here takes (none of them validate hex syntax either). */
|
|
230
|
+
export function contrastTextColor(hexColor: string): '#000000' | '#ffffff' {
|
|
231
|
+
const match = /^#?([0-9a-f]{6})$/i.exec(hexColor.trim());
|
|
232
|
+
if (!match) return '#ffffff';
|
|
233
|
+
const hex = match[1];
|
|
234
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
235
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
236
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
237
|
+
const luminance = (299 * r + 587 * g + 114 * b) / 1000;
|
|
238
|
+
return luminance > 150 ? '#000000' : '#ffffff';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Whether an href points off-site - has an explicit scheme (`https:`,
|
|
242
|
+
* `mailto:`, `tel:`, ...) or is protocol-relative (`//...`) - versus a
|
|
243
|
+
* same-site path, which this codebase always produces as a single
|
|
244
|
+
* leading slash (hrefForSlug() in [...slug].astro). Used everywhere a
|
|
245
|
+
* nav link is rendered to decide whether it should open in a new tab;
|
|
246
|
+
* deliberately a plain string check rather than a schema-level flag,
|
|
247
|
+
* so it applies uniformly to every href source (topbar.links, a
|
|
248
|
+
* switcher/tab pill pointing at a bare `href` container, a global
|
|
249
|
+
* dropdown's own link, a sidebar link leaf) without threading an
|
|
250
|
+
* `external` field through every one of those call sites. */
|
|
251
|
+
export function isExternalHref(href: string): boolean {
|
|
252
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------
|
|
256
|
+
// Icons - writedocs.json's `icon` fields (TabItem, DropdownItem, ProductItem,
|
|
257
|
+
// Card) are plain strings with no schema-level distinction between "an
|
|
258
|
+
// emoji, paste it verbatim" and "an icon-set name, look it up". Resolved
|
|
259
|
+
// here rather than validated in the schema, since both are valid uses of
|
|
260
|
+
// the same string field and the right rendering only becomes obvious once
|
|
261
|
+
// you look at the value's shape.
|
|
262
|
+
// ---------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export type IconResolution =
|
|
265
|
+
| { kind: 'iconify'; name: string } // ready for astro-icon/components' <Icon name=... />
|
|
266
|
+
| { kind: 'text'; value: string }; // literal text/emoji, rendered as-is
|
|
267
|
+
|
|
268
|
+
const DEFAULT_ICON_COLLECTION = 'lucide';
|
|
269
|
+
|
|
270
|
+
/** Resolves a writedocs.json `icon` string into either an Iconify icon id or
|
|
271
|
+
* literal text, covering three forms:
|
|
272
|
+
* - "collection:icon-name" (e.g. "mdi:server", "simple-icons:github")
|
|
273
|
+
* - used as-is against whatever @iconify-json/* collections are
|
|
274
|
+
* installed (see astro.config.mjs / package.json).
|
|
275
|
+
* - a bare name using only letters/digits/hyphens (e.g. "smartphone",
|
|
276
|
+
* "book-open") - defaults to the "lucide" collection, matching what
|
|
277
|
+
* docs.json-examples/ already assumes for plain icon-name strings.
|
|
278
|
+
* - anything else (an emoji, a symbol, arbitrary text) - rendered
|
|
279
|
+
* verbatim, preserving the original "just paste an emoji" behavior
|
|
280
|
+
* from before icon-library support existed. */
|
|
281
|
+
export function resolveIcon(icon: string): IconResolution {
|
|
282
|
+
if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: icon };
|
|
283
|
+
if (/^[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: `${DEFAULT_ICON_COLLECTION}:${icon}` };
|
|
284
|
+
return { kind: 'text', value: icon };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------
|
|
288
|
+
// OpenAPI navigation expansion - turns a group's `openapi: { src, path }`
|
|
289
|
+
// shorthand into a concrete `{ group, pages }` (one sub-group per tag),
|
|
290
|
+
// using the per-spec manifest generate-api-pages.js writes to
|
|
291
|
+
// writedocsTempDir()'s openapi/<path>/manifest.json before Astro starts (both
|
|
292
|
+
// `writedocs dev` and `writedocs build` run it first - see
|
|
293
|
+
// src/cli/dev.js, src/cli/build.js). Applied once, here in
|
|
294
|
+
// loadDocsConfig(), so every other function in this file
|
|
295
|
+
// (resolveSections, flattenNav, buildNavTree, ...) only ever sees plain
|
|
296
|
+
// page-slug strings and ordinary `{ group, pages }` nodes, and never
|
|
297
|
+
// needs to know the `openapi` group shorthand exists. A writedocs.json can
|
|
298
|
+
// have any number of these groups, each pointing at its own spec and
|
|
299
|
+
// mounted under its own `path` - generate-api-pages.js namespaces each
|
|
300
|
+
// spec's manifest/operations under that same `path`, so there's no
|
|
301
|
+
// cross-spec collision as long as every group uses a distinct `path`.
|
|
302
|
+
// ---------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
export interface OpenApiManifestEntry {
|
|
305
|
+
slug: string;
|
|
306
|
+
method: string;
|
|
307
|
+
path: string;
|
|
308
|
+
tags: string[];
|
|
309
|
+
title: string;
|
|
310
|
+
generated: boolean;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `specPath` is the owning group's own `openapi.path` (e.g. "/api") -
|
|
314
|
+
* generate-api-pages.js writes each spec's manifest under a directory
|
|
315
|
+
* named after that same value, so this only ever needs to know which
|
|
316
|
+
* group is asking, not anything about the spec's contents itself. */
|
|
317
|
+
function loadOpenApiManifest(contentDir: string, specPath: string): OpenApiManifestEntry[] | null {
|
|
318
|
+
const normalized = specPath.replace(/^\/+|\/+$/g, '');
|
|
319
|
+
const manifestPath = path.join(writedocsTempDir(contentDir), 'openapi', normalized, 'manifest.json');
|
|
320
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
321
|
+
try {
|
|
322
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
323
|
+
} catch {
|
|
324
|
+
return null; // stale/partial write from an interrupted previous run - treat as absent
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Expands one group's `openapi: { src, path }` into the `pages` array
|
|
329
|
+
* it stands in for - one sub-group per tag (in first-seen order),
|
|
330
|
+
* untagged operations collected into a trailing "Other" group. Returns
|
|
331
|
+
* an empty array (an empty, harmless group) rather than throwing if the
|
|
332
|
+
* manifest is missing - generate-api-pages.js always runs before this
|
|
333
|
+
* does (see src/cli/dev.js/build.js), so a missing manifest here means
|
|
334
|
+
* generation hasn't happened yet rather than a user error worth
|
|
335
|
+
* crashing the dev server over. */
|
|
336
|
+
function expandOpenApiGroupPages(openapiRef: { src: string; path: string }, contentDir: string): NavItem[] {
|
|
337
|
+
const manifest = loadOpenApiManifest(contentDir, openapiRef.path);
|
|
338
|
+
if (!manifest) return [];
|
|
339
|
+
|
|
340
|
+
const seenTags: string[] = [];
|
|
341
|
+
const byTag = new Map<string, string[]>();
|
|
342
|
+
const untagged: string[] = [];
|
|
343
|
+
for (const op of manifest) {
|
|
344
|
+
const tag = op.tags[0];
|
|
345
|
+
if (!tag) {
|
|
346
|
+
untagged.push(op.slug);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (!byTag.has(tag)) {
|
|
350
|
+
byTag.set(tag, []);
|
|
351
|
+
seenTags.push(tag);
|
|
352
|
+
}
|
|
353
|
+
byTag.get(tag)!.push(op.slug);
|
|
354
|
+
}
|
|
355
|
+
const groups: NavItem[] = seenTags.map((tag) => ({ group: tag, pages: byTag.get(tag)! }));
|
|
356
|
+
return untagged.length > 0 ? [...groups, { group: 'Other', pages: untagged }] : groups;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function expandOpenApiInPages(pages: NavItem[], contentDir: string): NavItem[] {
|
|
360
|
+
return pages.map((item): NavItem => {
|
|
361
|
+
if (typeof item === 'string') return item;
|
|
362
|
+
if ('href' in item) return item;
|
|
363
|
+
if ('openapi' in item) {
|
|
364
|
+
// A group using the { group, openapi: { src, path } } shorthand -
|
|
365
|
+
// replace it with a real { group, pages } node built from that
|
|
366
|
+
// spec's own manifest, so nothing downstream needs to know the
|
|
367
|
+
// shorthand ever existed.
|
|
368
|
+
return { group: item.group, pages: expandOpenApiGroupPages(item.openapi, contentDir) };
|
|
369
|
+
}
|
|
370
|
+
// An ordinary { group, page?, pages } - recurse into its own pages,
|
|
371
|
+
// since an openapi-group can be nested inside a hand-authored group
|
|
372
|
+
// too (e.g. wrapping it to add hand-written pages alongside the
|
|
373
|
+
// auto-generated ones).
|
|
374
|
+
return { ...item, pages: expandOpenApiInPages(item.pages, contentDir) };
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Mirrors walkSections()'s traversal (tabs/versions/languages/
|
|
379
|
+
* dropdowns/products, each bottoming out at `pages`), but rewrites
|
|
380
|
+
* rather than collects - every `pages` array anywhere in the tree gets
|
|
381
|
+
* run through expandOpenApiInPages(). */
|
|
382
|
+
function expandOpenApiInContainer(node: NavChildren, contentDir: string): NavChildren {
|
|
383
|
+
if ('pages' in node) return { pages: expandOpenApiInPages(node.pages, contentDir) };
|
|
384
|
+
if ('href' in node) return node;
|
|
385
|
+
if ('tabs' in node) {
|
|
386
|
+
return { tabs: node.tabs.map((t) => ({ ...t, ...expandOpenApiInContainer(t, contentDir) })) };
|
|
387
|
+
}
|
|
388
|
+
if ('versions' in node) {
|
|
389
|
+
return { versions: node.versions.map((v) => ({ ...v, ...expandOpenApiInContainer(v, contentDir) })) };
|
|
390
|
+
}
|
|
391
|
+
if ('languages' in node) {
|
|
392
|
+
return { languages: node.languages.map((l) => ({ ...l, ...expandOpenApiInContainer(l, contentDir) })) };
|
|
393
|
+
}
|
|
394
|
+
if ('dropdowns' in node) {
|
|
395
|
+
return { dropdowns: node.dropdowns.map((d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) })) };
|
|
396
|
+
}
|
|
397
|
+
if ('products' in node) {
|
|
398
|
+
return { products: node.products.map((p) => ({ ...p, ...expandOpenApiInContainer(p, contentDir) })) };
|
|
399
|
+
}
|
|
400
|
+
return node;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function expandOpenApiInNavigation(navigation: NavigationConfig, contentDir: string): NavigationConfig {
|
|
404
|
+
if (Array.isArray(navigation)) return expandOpenApiInPages(navigation, contentDir);
|
|
405
|
+
const expanded = { ...navigation, ...expandOpenApiInContainer(navigation as Container, contentDir) };
|
|
406
|
+
if (navigation.global?.dropdowns) {
|
|
407
|
+
expanded.global = {
|
|
408
|
+
dropdowns: navigation.global.dropdowns.map(
|
|
409
|
+
(d) => ({ ...d, ...expandOpenApiInContainer(d, contentDir) }) as DropdownItem
|
|
410
|
+
),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return expanded as NavigationConfig;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function loadDocsConfig(contentDir: string): DocsConfig {
|
|
417
|
+
const configPath = path.join(contentDir, 'writedocs.json');
|
|
418
|
+
if (!fs.existsSync(configPath)) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`[writedocs] writedocs.json not found at ${configPath}. Run "writedocs init" to scaffold one.`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
// A validacao em si (JSON.parse incluso) vive em validateDocsConfig
|
|
424
|
+
// (./config-schema.ts), o mesmo ponto de entrada que o `writedocs validate` e
|
|
425
|
+
// a plataforma usam - e o que garante que os tres reportem exatamente os
|
|
426
|
+
// mesmos problemas, com as mesmas palavras.
|
|
427
|
+
//
|
|
428
|
+
// As duas formas de falhar continuam saindo daqui EXATAMENTE como saiam antes
|
|
429
|
+
// desta extracao:
|
|
430
|
+
// - JSON quebrado: relanca o proprio SyntaxError do JSON.parse (mesmo tipo,
|
|
431
|
+
// mesma mensagem que o runtime produz);
|
|
432
|
+
// - schema invalido: o mesmo Error, com o mesmo texto, montado a partir do
|
|
433
|
+
// mesmo formatador que a CLI usa.
|
|
434
|
+
const result = validateDocsConfig(fs.readFileSync(configPath, 'utf-8'));
|
|
435
|
+
if (!result.ok) {
|
|
436
|
+
if (result.parseError) throw result.parseError;
|
|
437
|
+
throw new Error(`[writedocs] writedocs.json failed validation:\n${formatValidationIssues(result.issues)}`);
|
|
438
|
+
}
|
|
439
|
+
// A no-op pass over ordinary navigation trees (no openapi groups) -
|
|
440
|
+
// always run, rather than gated behind a global manifest check, since
|
|
441
|
+
// there's no longer a single global spec to check for.
|
|
442
|
+
result.data.navigation = expandOpenApiInNavigation(result.data.navigation, contentDir);
|
|
443
|
+
return result.data;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- Locating a page by file id, independent of its effective slug -----
|
|
447
|
+
//
|
|
448
|
+
// writedocs.json's `pages` arrays, and every helper above/below that walks
|
|
449
|
+
// them (flattenNav, firstSlugOf, containerContainsSlug, buildNavTree, ...),
|
|
450
|
+
// always identify a page by its file id - its path relative to the
|
|
451
|
+
// content directory (project root), extension stripped, with a trailing
|
|
452
|
+
// `/index` segment dropped (matching Astro's own default id computation -
|
|
453
|
+
// see the `/index` note below) - regardless of how that page ends up
|
|
454
|
+
// being served. docs/ is not special: a file at `docs/guides/x.mdx` has
|
|
455
|
+
// file id `docs/guides/x`, the exact same rule applied to a file
|
|
456
|
+
// anywhere else in the project.
|
|
457
|
+
//
|
|
458
|
+
// A page's actual URL is a separate question, and Astro's own glob()
|
|
459
|
+
// content loader already has a first-class answer for it: if a page's
|
|
460
|
+
// frontmatter sets `slug`, `entry.id` (and therefore the route Astro
|
|
461
|
+
// builds for it) becomes that value verbatim instead of the file-path
|
|
462
|
+
// default - see astro/dist/content/loaders/glob.js's generateIdDefault:
|
|
463
|
+
// `if (data.slug) return data.slug`. content.config.ts's `slug` schema
|
|
464
|
+
// field is deliberately the same field Astro already recognizes, so
|
|
465
|
+
// nothing here needs to reimplement the override or track it separately -
|
|
466
|
+
// it only needs a way to find a page BY file id (to resolve writedocs.json's
|
|
467
|
+
// references) even once `entry.id` no longer equals it.
|
|
468
|
+
//
|
|
469
|
+
// --- Page discovery -------------------------------------------------
|
|
470
|
+
//
|
|
471
|
+
// Any .md/.mdx file anywhere under the content directory becomes a page
|
|
472
|
+
// candidate the moment it has *any* frontmatter block at all - docs/ has
|
|
473
|
+
// no special status here, it's just a folder like any other (a
|
|
474
|
+
// conventional, recommended place to put most pages, not a requirement).
|
|
475
|
+
// content.config.ts's `pages` collection is the same docsSchema applied
|
|
476
|
+
// to exactly this file set. A file with zero frontmatter (no `---` block
|
|
477
|
+
// whatsoever) is never a page candidate - a snippet (see
|
|
478
|
+
// docs/dev/docs/snippets.mdx) typically has none, which is what lets it
|
|
479
|
+
// live anywhere without tripping schema validation. A file that *does*
|
|
480
|
+
// open a frontmatter block but is missing a required field (`title`)
|
|
481
|
+
// still fails validation exactly as before - only "no frontmatter at
|
|
482
|
+
// all" is new; a genuine mistake (frontmatter present, title forgotten)
|
|
483
|
+
// stays a loud build error rather than being silently treated as
|
|
484
|
+
// non-page content.
|
|
485
|
+
//
|
|
486
|
+
// A handful of directories are never scanned regardless of what's in
|
|
487
|
+
// them - build output, dependencies, and writedocs' own working
|
|
488
|
+
// directories, none of which a site author would ever intend as page
|
|
489
|
+
// content. Only excluded at the content directory's own top level (a
|
|
490
|
+
// hand-authored `guides/dist-notes.mdx` isn't mistaken for the build
|
|
491
|
+
// output directory `dist/` just because a folder two levels down happens
|
|
492
|
+
// to also be named `dist`).
|
|
493
|
+
const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
|
|
494
|
+
|
|
495
|
+
/** Recursively finds every .md/.mdx file under `contentDir` that has a
|
|
496
|
+
* frontmatter block, skipping the handful of build/dependency
|
|
497
|
+
* directories a real content directory tends to also contain (see
|
|
498
|
+
* EXCLUDED_TOP_LEVEL_DIRS above) - docs/ is scanned exactly like any
|
|
499
|
+
* other folder, no special-casing. Returns POSIX-relative paths (from
|
|
500
|
+
* `contentDir`) suitable to hand straight to Astro's `glob()` loader as
|
|
501
|
+
* a literal `pattern` array - see content.config.ts's `pages`
|
|
502
|
+
* collection, and [...slug].astro, which needs the identical list to
|
|
503
|
+
* decide whether calling `getCollection('pages')` is worth doing at all
|
|
504
|
+
* (see content.config.ts's own comment on why an empty collection still
|
|
505
|
+
* needs to exist, just backed by a no-op loader, to avoid Astro's "does
|
|
506
|
+
* not exist or is empty" warning). Also reused directly by
|
|
507
|
+
* astro.config.mjs's noindex/sitemap scan, so that scan always sees
|
|
508
|
+
* exactly the same file set that actually becomes a page - no risk of
|
|
509
|
+
* the two drifting apart. Synchronous and re-run from scratch wherever
|
|
510
|
+
* it's called rather than cached and shared across modules - consistent
|
|
511
|
+
* with how loadDocsConfig() itself is already called repeatedly across
|
|
512
|
+
* this codebase instead of threaded through as shared state, and cheap
|
|
513
|
+
* enough in practice (a docs site's own file count) not to matter. */
|
|
514
|
+
export function findAllPages(contentDir: string): string[] {
|
|
515
|
+
const results: string[] = [];
|
|
516
|
+
function walk(dir: string, relBase: string) {
|
|
517
|
+
let entries: fs.Dirent[];
|
|
518
|
+
try {
|
|
519
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
520
|
+
} catch {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
for (const entry of entries) {
|
|
524
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
525
|
+
const abs = path.join(dir, entry.name);
|
|
526
|
+
if (entry.isDirectory()) {
|
|
527
|
+
if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
|
|
528
|
+
walk(abs, rel);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (!entry.isFile() || !/\.mdx?$/i.test(entry.name)) continue;
|
|
532
|
+
let raw: string;
|
|
533
|
+
try {
|
|
534
|
+
raw = fs.readFileSync(abs, 'utf-8');
|
|
535
|
+
} catch {
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const { data } = matter(raw);
|
|
539
|
+
if (Object.keys(data).length === 0) continue; // no frontmatter at all - not a page
|
|
540
|
+
results.push(rel);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
walk(contentDir, '');
|
|
544
|
+
return results;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
|
|
548
|
+
* `public/` included - auto-loaded site-wide with zero `writedocs.json`
|
|
549
|
+
* config, on top of (not instead of) the explicit `scripts` field
|
|
550
|
+
* (bannerSchema and friends, above). Drop a file in, it loads;
|
|
551
|
+
* there's no field naming which ones to use, matching the same "just
|
|
552
|
+
* works" convention `docs/`'s own file discovery already follows (see
|
|
553
|
+
* findAllPages() above / `content-pipeline.mdx`) - a site author already
|
|
554
|
+
* drops content files in and expects them found, rather than also
|
|
555
|
+
* listing every one in writedocs.json.
|
|
556
|
+
*
|
|
557
|
+
* Two separate walks, because `public/` needs different treatment than
|
|
558
|
+
* everywhere else:
|
|
559
|
+
*
|
|
560
|
+
* - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
|
|
561
|
+
* `snippets/`, any custom folder) - reused as the walk-with-exclusions
|
|
562
|
+
* shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
|
|
563
|
+
* set (skipped only at the project root, same as there), so `dist/`,
|
|
564
|
+
* `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
|
|
565
|
+
* half only - see below) `public/` are never walked into. BaseLayout.astro
|
|
566
|
+
* reads each one's raw content and inlines it as a `<style>`/
|
|
567
|
+
* `<script is:inline>` tag.
|
|
568
|
+
* - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
|
|
569
|
+
* walked separately (starting from `<contentDir>/public` rather than
|
|
570
|
+
* `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
|
|
571
|
+
* doesn't apply here - there's no `public/public/` or `public/dist/`
|
|
572
|
+
* convention to guard against). Returned as public-URL-rooted hrefs
|
|
573
|
+
* (a leading `/`, no `public` segment - `public/custom.css` becomes
|
|
574
|
+
* `/custom.css`) rather than content-dir-relative paths, since these
|
|
575
|
+
* files are already served as static assets at exactly that URL once
|
|
576
|
+
* Astro copies `public/` into the build output. BaseLayout.astro
|
|
577
|
+
* renders these as ordinary `<link rel="stylesheet">`/`<script src>`
|
|
578
|
+
* tags pointing at that URL instead of inlining their content -
|
|
579
|
+
* inlining would duplicate every byte (once in the page's own HTML,
|
|
580
|
+
* once more as the independently-fetchable static file at that same
|
|
581
|
+
* URL) for no benefit, where a `<link>`/`<script src>` gets normal
|
|
582
|
+
* browser caching across pages instead of repeating the content on
|
|
583
|
+
* every single page's markup.
|
|
584
|
+
*
|
|
585
|
+
* Both halves are broader than they might sound - a stray `.js` file
|
|
586
|
+
* kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
|
|
587
|
+
* (a snippet's own local helper, an image gallery's lightbox script
|
|
588
|
+
* someone dropped in `public/` to reference from a raw `<script src>`
|
|
589
|
+
* in an .mdx file, say) gets auto-injected sitewide the same as a
|
|
590
|
+
* deliberate one; there's no separate "this one's just tooling" signal
|
|
591
|
+
* to opt out of the convention short of renaming its extension.
|
|
592
|
+
*
|
|
593
|
+
* Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
|
|
594
|
+
* by their respective path for deterministic load order across rebuilds
|
|
595
|
+
* - same reasoning as llms.txt's own alphabetical-by-slug sort (see
|
|
596
|
+
* llms.txt.ts) - filesystem readdir order isn't guaranteed portable
|
|
597
|
+
* across OSes or directory-walk order otherwise.
|
|
598
|
+
*
|
|
599
|
+
* `css`/`js` return POSIX-separated paths relative to `contentDir`, not
|
|
600
|
+
* absolute paths or file contents - BaseLayout.astro (the sole caller)
|
|
601
|
+
* resolves and reads each one's content itself, right before inlining
|
|
602
|
+
* it, so a file's content is always current as of that specific
|
|
603
|
+
* request/build rather than cached here across a `writedocs dev`
|
|
604
|
+
* session. `publicCss`/`publicJs` return the public-URL hrefs described
|
|
605
|
+
* above - nothing to read, Astro's own static-file serving/copy already
|
|
606
|
+
* handles those. */
|
|
607
|
+
export function findRootAssets(
|
|
608
|
+
contentDir: string
|
|
609
|
+
): { css: string[]; js: string[]; publicCss: string[]; publicJs: string[] } {
|
|
610
|
+
const css: string[] = [];
|
|
611
|
+
const js: string[] = [];
|
|
612
|
+
function walk(dir: string, relBase: string) {
|
|
613
|
+
let entries: fs.Dirent[];
|
|
614
|
+
try {
|
|
615
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
616
|
+
} catch {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
for (const entry of entries) {
|
|
620
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
621
|
+
const abs = path.join(dir, entry.name);
|
|
622
|
+
if (entry.isDirectory()) {
|
|
623
|
+
if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
|
|
624
|
+
walk(abs, rel);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (!entry.isFile()) continue;
|
|
628
|
+
if (/\.css$/i.test(entry.name)) css.push(rel);
|
|
629
|
+
else if (/\.js$/i.test(entry.name)) js.push(rel);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
walk(contentDir, '');
|
|
633
|
+
css.sort();
|
|
634
|
+
js.sort();
|
|
635
|
+
|
|
636
|
+
const publicCss: string[] = [];
|
|
637
|
+
const publicJs: string[] = [];
|
|
638
|
+
function walkPublic(dir: string, relBase: string) {
|
|
639
|
+
let entries: fs.Dirent[];
|
|
640
|
+
try {
|
|
641
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
642
|
+
} catch {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
for (const entry of entries) {
|
|
646
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
647
|
+
const abs = path.join(dir, entry.name);
|
|
648
|
+
if (entry.isDirectory()) {
|
|
649
|
+
walkPublic(abs, rel);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (!entry.isFile()) continue;
|
|
653
|
+
if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
|
|
654
|
+
else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
walkPublic(path.join(contentDir, 'public'), '');
|
|
658
|
+
publicCss.sort();
|
|
659
|
+
publicJs.sort();
|
|
660
|
+
|
|
661
|
+
return { css, js, publicCss, publicJs };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The root-relative URL paths (leading `/`, e.g. `/images/hero.svg`)
|
|
665
|
+
* referenced by the writedocs.json/styles fields that point at a static asset
|
|
666
|
+
* *by URL* rather than embedding it inline: `styles.favicon`,
|
|
667
|
+
* `styles.logo` (both the plain-string and `{light, dark}` object forms),
|
|
668
|
+
* `styles.background.images.{light,dark}`, and `seo.ogImage`. External
|
|
669
|
+
* URLs (anything not starting with `/` - `https://...`, mainly) are
|
|
670
|
+
* filtered out, since those need no local file resolution at all.
|
|
671
|
+
* Deduped, since e.g. `logo.light` and `background.images.light`
|
|
672
|
+
* coincidentally pointing at the same file shouldn't resolve/copy it
|
|
673
|
+
* twice.
|
|
674
|
+
*
|
|
675
|
+
* Used by `stylesAssetFallback()` (`styles-asset-integration.js`,
|
|
676
|
+
* imported from `astro.config.mjs`) to let every one of these resolve
|
|
677
|
+
* from *anywhere* in the project, not just `public/` - previously,
|
|
678
|
+
* `public/` was the one place `styles.background.images` (etc.) had to
|
|
679
|
+
* live, since Astro's own `publicDir` copy is the only thing that ever
|
|
680
|
+
* served them; everywhere else in this codebase's own "drop a file
|
|
681
|
+
* anywhere, it's found" convention (`findRootAssets()` right above,
|
|
682
|
+
* `findAllPages()` for content) already worked project-wide. See that
|
|
683
|
+
* integration's own comment for the actual resolution mechanism (a dev-time
|
|
684
|
+
* middleware plus a post-build copy step, not a duplicated `publicDir`)
|
|
685
|
+
* and why a straight copy-into-`public/`-on-disk approach was rejected.
|
|
686
|
+
*
|
|
687
|
+
* Logo light/dark resolution here deliberately mirrors BaseLayout.astro's
|
|
688
|
+
* own `logoLight`/`logoDark` derivation exactly (a plain-string `logo`
|
|
689
|
+
* counts as both light and dark) - two independent implementations of
|
|
690
|
+
* that same union-unwrapping would only be one accidental edit away from
|
|
691
|
+
* disagreeing with each other. `footer.logo` (footerSchema) gets the
|
|
692
|
+
* same treatment, independently of `styles.logo` - both are collected
|
|
693
|
+
* unconditionally here (not just whichever one BaseLayout.astro would
|
|
694
|
+
* actually end up using for a given page), since this function has no
|
|
695
|
+
* page context to know which page modes render a footer at all; an
|
|
696
|
+
* unreferenced path collected here that never actually renders anywhere
|
|
697
|
+
* is harmless (nothing copies/resolves a file that's never requested),
|
|
698
|
+
* but a real footer.logo file silently 404ing because this function
|
|
699
|
+
* didn't know to resolve it is exactly the bug this comment is warning
|
|
700
|
+
* future edits away from repeating.
|
|
701
|
+
*
|
|
702
|
+
* Per-page frontmatter `seo.ogImage` overrides are deliberately out of
|
|
703
|
+
* scope - those aren't visible from a `DocsConfig` alone (they live in
|
|
704
|
+
* each page's own frontmatter, merged in per-request by [...slug].astro/
|
|
705
|
+
* BaseLayout.astro), and resolving every page's own override would need
|
|
706
|
+
* a full content scan this function has no reason to also become. A
|
|
707
|
+
* page overriding `seo.ogImage` to something outside `public/` still
|
|
708
|
+
* needs to put it there for now. */
|
|
709
|
+
export function collectConfiguredAssetPaths(config: DocsConfig): string[] {
|
|
710
|
+
const logo = config.styles.logo;
|
|
711
|
+
const logoLight = typeof logo === 'string' ? logo : logo?.light;
|
|
712
|
+
const logoDark = typeof logo === 'string' ? logo : logo?.dark;
|
|
713
|
+
const footerLogo = config.footer.logo;
|
|
714
|
+
const footerLogoLight = typeof footerLogo === 'string' ? footerLogo : footerLogo?.light;
|
|
715
|
+
const footerLogoDark = typeof footerLogo === 'string' ? footerLogo : footerLogo?.dark;
|
|
716
|
+
const fonts = config.styles.fonts;
|
|
717
|
+
const raw: (string | undefined)[] = [
|
|
718
|
+
config.styles.favicon,
|
|
719
|
+
logoLight,
|
|
720
|
+
logoDark,
|
|
721
|
+
footerLogoLight,
|
|
722
|
+
footerLogoDark,
|
|
723
|
+
config.styles.background?.images?.light,
|
|
724
|
+
config.styles.background?.images?.dark,
|
|
725
|
+
config.seo?.ogImage,
|
|
726
|
+
// A `styles.fonts` `source` is only ever handled here when it's a
|
|
727
|
+
// project-relative path (the `p.startsWith('/')` filter below already
|
|
728
|
+
// excludes both Google Font names, which never start with "/", and a
|
|
729
|
+
// full https:// external font URL, which the browser fetches directly
|
|
730
|
+
// - neither needs resolving/copying through this pipeline at all).
|
|
731
|
+
fonts?.source,
|
|
732
|
+
fonts?.heading?.source,
|
|
733
|
+
fonts?.body?.source,
|
|
734
|
+
];
|
|
735
|
+
const paths = raw.filter((p): p is string => Boolean(p) && p.startsWith('/'));
|
|
736
|
+
return Array.from(new Set(paths));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export interface DocsEntryLike {
|
|
740
|
+
id: string;
|
|
741
|
+
filePath?: string;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Recovers a content entry's writedocs.json-facing file id (its path
|
|
745
|
+
* relative to the content directory, extension stripped, trailing
|
|
746
|
+
* `/index` dropped), independent of any frontmatter `slug` override -
|
|
747
|
+
* `entry.filePath` is always root-relative and POSIX-separated (how
|
|
748
|
+
* Astro's content layer records it, relative to whatever `--root` Astro
|
|
749
|
+
* itself was invoked with - see run-astro.js), so both it and the
|
|
750
|
+
* content directory are resolved to absolute paths before comparing,
|
|
751
|
+
* rather than string-matching a prefix that could be relative,
|
|
752
|
+
* absolute, or platform-separated inconsistently.
|
|
753
|
+
*
|
|
754
|
+
* The trailing-`/index`-drop mirrors Astro's own default id computation
|
|
755
|
+
* exactly (`getContentEntryIdAndSlug()` in astro/dist/content/
|
|
756
|
+
* utils.js: segments are joined then `.replace(/\/index$/, '')`) -
|
|
757
|
+
* without it, a nested index file like `docs/guides/index.mdx` (Astro's
|
|
758
|
+
* own default id: "docs/guides") would recover the wrong file id here
|
|
759
|
+
* ("docs/guides/index"), and a writedocs.json reference written to match the
|
|
760
|
+
* page's real URL would fail to resolve. A single-segment `index.mdx`
|
|
761
|
+
* at the content root is unaffected either way - the regex requires a
|
|
762
|
+
* preceding `/`, which a bare "index" doesn't have (matching Astro's
|
|
763
|
+
* own behavior: a root-level index.mdx keeps file id "index", not "").
|
|
764
|
+
*
|
|
765
|
+
* Full per-segment slugification (github-slugger, applied by Astro to
|
|
766
|
+
* every path segment) is deliberately *not* replicated here - every
|
|
767
|
+
* filename in this codebase's own fixtures and every filename this
|
|
768
|
+
* function needs to have handled correctly is already slug-safe
|
|
769
|
+
* (lowercase, hyphenated, no spaces/unicode), so slugification is
|
|
770
|
+
* always a no-op in practice; only the `/index`-stripping behavior is
|
|
771
|
+
* reproduced, since that's the one part of Astro's algorithm this
|
|
772
|
+
* change newly exercises (a file that used to be a collection's own
|
|
773
|
+
* base-root index, exempt from stripping, and is now nested one level
|
|
774
|
+
* deeper).
|
|
775
|
+
*
|
|
776
|
+
* Entries from the `generatedDocs` collection (auto-generated OpenAPI
|
|
777
|
+
* stub pages - see content.config.ts / generate-api-pages.js) live
|
|
778
|
+
* under writedocsTempDir()'s generated-docs/ directory - an OS temp
|
|
779
|
+
* directory location entirely outside contentDir, not a subdirectory
|
|
780
|
+
* of it - so `relativeToContentDir` for one of these always starts with
|
|
781
|
+
* `..` (walking back out of contentDir to reach it), and the check
|
|
782
|
+
* right below falls back to `entry.id` instead of computing a
|
|
783
|
+
* nonsensical id relative to a directory the file was never actually
|
|
784
|
+
* under. Those entries always set their own `slug` frontmatter
|
|
785
|
+
* explicitly, so there's no independent "file path" identity worth
|
|
786
|
+
* recovering for them the way there is for a hand-written page that
|
|
787
|
+
* might move around - `entry.id` (Astro's already-resolved slug)
|
|
788
|
+
* already IS the exact value generate-api-pages.js's own manifest
|
|
789
|
+
* references them by. */
|
|
790
|
+
export function fileIdForEntry(
|
|
791
|
+
contentDir: string,
|
|
792
|
+
packageRoot: string,
|
|
793
|
+
entry: DocsEntryLike
|
|
794
|
+
): string {
|
|
795
|
+
if (!entry.filePath) return entry.id;
|
|
796
|
+
const absoluteContentDir = path.resolve(contentDir);
|
|
797
|
+
const absoluteFilePath = path.resolve(packageRoot, entry.filePath);
|
|
798
|
+
const relativeToContentDir = path.relative(absoluteContentDir, absoluteFilePath);
|
|
799
|
+
if (relativeToContentDir.startsWith('..')) return entry.id;
|
|
800
|
+
const posixRelative = relativeToContentDir.split(path.sep).join('/');
|
|
801
|
+
if (EXCLUDED_TOP_LEVEL_DIRS.has(posixRelative.split('/')[0])) return entry.id;
|
|
802
|
+
return posixRelative.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Normalizes a raw `entry.id` into the bare, slash-free form every
|
|
806
|
+
* route/href in this codebase assumes. Once a page sets a frontmatter
|
|
807
|
+
* `slug`, `entry.id` becomes that value completely verbatim - Astro's
|
|
808
|
+
* glob loader applies no normalization of its own (see
|
|
809
|
+
* generateIdDefault in astro/dist/content/loaders/glob.js) - so
|
|
810
|
+
* `slug: /` or `slug: /guides/new-name` (both natural things to write,
|
|
811
|
+
* mirroring how every href elsewhere in writedocs.json already has a
|
|
812
|
+
* leading slash) would otherwise produce broken multi-slash hrefs
|
|
813
|
+
* wherever hrefForSlug wraps the value in its own `/${slug}/`, and a
|
|
814
|
+
* bare "/" would additionally fail to be recognized as claiming root,
|
|
815
|
+
* colliding with the synthetic root-redirect route. */
|
|
816
|
+
export function normalizeEntryId(id: string): string {
|
|
817
|
+
const trimmed = id.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
818
|
+
return trimmed === '' ? 'index' : trimmed;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export interface FlatNavEntry {
|
|
822
|
+
slug: string;
|
|
823
|
+
group: string | null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function flattenNav(navigation: NavItem[], group: string | null = null): FlatNavEntry[] {
|
|
827
|
+
return navigation.flatMap((item): FlatNavEntry[] => {
|
|
828
|
+
if (typeof item === 'string') {
|
|
829
|
+
return [{ slug: item, group }];
|
|
830
|
+
}
|
|
831
|
+
if ('href' in item) return []; // external link leaf, not a content page - no route/prev-next entry
|
|
832
|
+
// loadDocsConfig() always expands `{ group, openapi }` shorthand into
|
|
833
|
+
// a real `{ group, pages }` before anything reaches here (see
|
|
834
|
+
// expandOpenApiInNavigation() above) - this is just a defensive
|
|
835
|
+
// no-op for the shouldn't-happen case of an unexpanded node.
|
|
836
|
+
if ('openapi' in item) return [];
|
|
837
|
+
const ownPage: FlatNavEntry[] = item.page ? [{ slug: item.page, group: item.group }] : [];
|
|
838
|
+
return [...ownPage, ...flattenNav(item.pages, item.group)];
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// --- Sections -------------------------------------------------------
|
|
843
|
+
//
|
|
844
|
+
// A Section is the atomic unit that owns one page tree and therefore one
|
|
845
|
+
// sidebar - every real content page belongs to exactly one Section,
|
|
846
|
+
// whichever `pages` leaf it's listed under, however deep in the
|
|
847
|
+
// tabs/versions/languages/dropdowns/products tree that leaf lives.
|
|
848
|
+
//
|
|
849
|
+
// A Section's `path` records the full chain of containers from the
|
|
850
|
+
// navigation root down to it, one PathSegment per level. This is
|
|
851
|
+
// everything the topbar needs to render the right selector controls
|
|
852
|
+
// (tabs bar, version dropdown, ...) and highlight the right option in
|
|
853
|
+
// each - without the router or layout needing to know how deep or in
|
|
854
|
+
// what order the site author nested things.
|
|
855
|
+
|
|
856
|
+
export type PathSegment =
|
|
857
|
+
| { kind: 'tab'; items: TabItem[]; index: number }
|
|
858
|
+
| { kind: 'version'; items: VersionItem[]; index: number }
|
|
859
|
+
| { kind: 'language'; items: LanguageItem[]; index: number }
|
|
860
|
+
| { kind: 'dropdown'; items: DropdownItem[]; index: number }
|
|
861
|
+
| { kind: 'product'; items: ProductItem[]; index: number };
|
|
862
|
+
|
|
863
|
+
export interface Section {
|
|
864
|
+
pages: NavItem[];
|
|
865
|
+
path: PathSegment[];
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
type Container = NavChildren;
|
|
869
|
+
type NamedContainer = TabItem | VersionItem | LanguageItem | DropdownItem | ProductItem;
|
|
870
|
+
|
|
871
|
+
function walkSections(node: Container, path: PathSegment[], out: Section[]): void {
|
|
872
|
+
if ('pages' in node) {
|
|
873
|
+
out.push({ pages: node.pages, path });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if ('href' in node) return; // external link, not a Section
|
|
877
|
+
if ('tabs' in node) {
|
|
878
|
+
node.tabs.forEach((item, index) =>
|
|
879
|
+
walkSections(item, [...path, { kind: 'tab', items: node.tabs, index }], out)
|
|
880
|
+
);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if ('versions' in node) {
|
|
884
|
+
node.versions.forEach((item, index) =>
|
|
885
|
+
walkSections(item, [...path, { kind: 'version', items: node.versions, index }], out)
|
|
886
|
+
);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if ('languages' in node) {
|
|
890
|
+
node.languages.forEach((item, index) =>
|
|
891
|
+
walkSections(item, [...path, { kind: 'language', items: node.languages, index }], out)
|
|
892
|
+
);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if ('dropdowns' in node) {
|
|
896
|
+
node.dropdowns.forEach((item, index) =>
|
|
897
|
+
walkSections(item, [...path, { kind: 'dropdown', items: node.dropdowns, index }], out)
|
|
898
|
+
);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if ('products' in node) {
|
|
902
|
+
node.products.forEach((item, index) =>
|
|
903
|
+
walkSections(item, [...path, { kind: 'product', items: node.products, index }], out)
|
|
904
|
+
);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export function resolveSections(navigation: NavigationConfig): Section[] {
|
|
910
|
+
if (Array.isArray(navigation)) return [{ pages: navigation, path: [] }];
|
|
911
|
+
const sections: Section[] = [];
|
|
912
|
+
walkSections(navigation as Container, [], sections);
|
|
913
|
+
// global.dropdowns sits outside the primary pattern, but its pages
|
|
914
|
+
// still need routes generated for them - walk each entry too, with an
|
|
915
|
+
// empty path (they don't participate in the tabs/version/etc.
|
|
916
|
+
// selector chain, only in the always-visible globalDropdowns list).
|
|
917
|
+
for (const dropdown of navigation.global?.dropdowns ?? []) {
|
|
918
|
+
walkSections(dropdown, [], sections);
|
|
919
|
+
}
|
|
920
|
+
return sections;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Which Section (by index into resolveSections()'s result) a page belongs to. */
|
|
924
|
+
export function findSectionIndexForSlug(sections: Section[], slug: string): number {
|
|
925
|
+
const index = sections.findIndex((s) => flattenNav(s.pages).some((entry) => entry.slug === slug));
|
|
926
|
+
return index === -1 ? 0 : index;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** The always-visible topbar dropdown list - independent of whichever
|
|
930
|
+
* primary pattern/section is active (Mintlify's equivalent is
|
|
931
|
+
* `navigation.global.anchors`). */
|
|
932
|
+
export function resolveGlobalDropdowns(navigation: NavigationConfig): DropdownItem[] {
|
|
933
|
+
if (Array.isArray(navigation)) return [];
|
|
934
|
+
return navigation.global?.dropdowns ?? [];
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** The first real page slug reachable by descending into a container's
|
|
938
|
+
* content, however deep - used to compute where a selector option (a
|
|
939
|
+
* tab, a version, ...) navigates to when chosen. Null for an external
|
|
940
|
+
* `href` leaf, which the caller renders as a plain link using the
|
|
941
|
+
* node's own href instead. Versions prefer whichever entry is marked
|
|
942
|
+
* `default` (falling back to the first), matching Mintlify's version
|
|
943
|
+
* default rule. */
|
|
944
|
+
/** Tries `firstSlugOf()` against each item in order, returning the first
|
|
945
|
+
* non-null result - a plain `items[0]` pick (the previous behavior)
|
|
946
|
+
* breaks the moment that first sibling happens to be a bare `href` leaf
|
|
947
|
+
* (returns null, with nothing that tries the next one), which is a real
|
|
948
|
+
* case now that any container - not just a nested dropdown - can be a
|
|
949
|
+
* bare external link (e.g. a `tabs` array whose first entry is
|
|
950
|
+
* `{ tab: "Status", href: "..." }`). */
|
|
951
|
+
function firstSlugAmong(items: Container[]): string | null {
|
|
952
|
+
for (const item of items) {
|
|
953
|
+
const slug = firstSlugOf(item);
|
|
954
|
+
if (slug !== null) return slug;
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export function firstSlugOf(node: Container): string | null {
|
|
960
|
+
if ('pages' in node) return flattenNav(node.pages)[0]?.slug ?? null;
|
|
961
|
+
if ('href' in node) return null;
|
|
962
|
+
if ('tabs' in node) return firstSlugAmong(node.tabs);
|
|
963
|
+
if ('versions' in node) {
|
|
964
|
+
// Try the `default`-tagged version(s) first (matching the old
|
|
965
|
+
// "preferred" pick), then fall through to the rest in order - same
|
|
966
|
+
// href-leaf-with-no-fallback concern as `tabs` above, just with an
|
|
967
|
+
// extra preference pass in front of it.
|
|
968
|
+
const defaults = node.versions.filter((v) => v.default);
|
|
969
|
+
const rest = node.versions.filter((v) => !v.default);
|
|
970
|
+
return firstSlugAmong([...defaults, ...rest]);
|
|
971
|
+
}
|
|
972
|
+
if ('languages' in node) return firstSlugAmong(node.languages);
|
|
973
|
+
if ('dropdowns' in node) return firstSlugAmong(node.dropdowns);
|
|
974
|
+
if ('products' in node) return firstSlugAmong(node.products);
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** For a version/language switcher, finds where the reader's current
|
|
979
|
+
* page would live inside a *different* version/language's own subtree,
|
|
980
|
+
* so switching keeps them on the same conceptual page instead of always
|
|
981
|
+
* bouncing to that option's first page (see buildSelectors() below,
|
|
982
|
+
* which is the only caller). "Same page" is approximated positionally
|
|
983
|
+
* rather than by name, since nothing guarantees a version/language's
|
|
984
|
+
* own identifier lines up with its pages' file paths - a version
|
|
985
|
+
* tagged "2025-09" could just as easily keep its pages under a "v1"
|
|
986
|
+
* folder with no relation to that string, so there's no naming
|
|
987
|
+
* convention to key off safely; this heuristic only ever compares tree
|
|
988
|
+
* position, never path text. (docs.json-examples/00-kitchen-sink/'s own
|
|
989
|
+
* versions - "2025-09"/"2026-01" under Core Platform - happen to have
|
|
990
|
+
* folder names that line up with their version strings, so it doesn't
|
|
991
|
+
* demonstrate the mismatched-folder-name case directly; the guarantee
|
|
992
|
+
* still doesn't exist regardless of what one fixture happens to do.)
|
|
993
|
+
*
|
|
994
|
+
* Two things have to line up for a page to count as "the same" one:
|
|
995
|
+
* first, `remainingPath` (whatever tab/product/dropdown was chosen
|
|
996
|
+
* *below* the version/language being switched, on the reader's actual
|
|
997
|
+
* page) has to exist at the same index in `item`'s own subtree too -
|
|
998
|
+
* an option isn't guaranteed to nest the same way that many levels
|
|
999
|
+
* down (a version could add/remove a tab), so any mismatch here bails
|
|
1000
|
+
* out to null immediately rather than guessing. Second, once both
|
|
1001
|
+
* bottom out at a leaf `pages` list, `position` (the reader's own page
|
|
1002
|
+
* index within *its* pages list) has to exist in `item`'s pages list
|
|
1003
|
+
* too - two versions/languages of the same docs are usually authored
|
|
1004
|
+
* with matching page order even when the file paths differ, so this
|
|
1005
|
+
* is a reasonable proxy for "the same page" without relying on names.
|
|
1006
|
+
*
|
|
1007
|
+
* Returns null - meaning "no equivalent page, fall back to
|
|
1008
|
+
* firstSlugOf()" - on any structural mismatch or an out-of-range
|
|
1009
|
+
* position, rather than guessing at a wrong page. */
|
|
1010
|
+
export function equivalentPageIn(
|
|
1011
|
+
item: Container,
|
|
1012
|
+
remainingPath: PathSegment[],
|
|
1013
|
+
position: number
|
|
1014
|
+
): string | null {
|
|
1015
|
+
if ('pages' in item) return flattenNav(item.pages)[position]?.slug ?? null;
|
|
1016
|
+
if ('href' in item) return null;
|
|
1017
|
+
const [next, ...rest] = remainingPath;
|
|
1018
|
+
if (!next) return null; // the reader's own page didn't go this deep - no basis to pick a branch
|
|
1019
|
+
if ('tabs' in item && next.kind === 'tab') {
|
|
1020
|
+
const target = item.tabs[next.index];
|
|
1021
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1022
|
+
}
|
|
1023
|
+
if ('versions' in item && next.kind === 'version') {
|
|
1024
|
+
const target = item.versions[next.index];
|
|
1025
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1026
|
+
}
|
|
1027
|
+
if ('languages' in item && next.kind === 'language') {
|
|
1028
|
+
const target = item.languages[next.index];
|
|
1029
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1030
|
+
}
|
|
1031
|
+
if ('dropdowns' in item && next.kind === 'dropdown') {
|
|
1032
|
+
const target = item.dropdowns[next.index];
|
|
1033
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1034
|
+
}
|
|
1035
|
+
if ('products' in item && next.kind === 'product') {
|
|
1036
|
+
const target = item.products[next.index];
|
|
1037
|
+
return target ? equivalentPageIn(target, rest, position) : null;
|
|
1038
|
+
}
|
|
1039
|
+
return null; // structural mismatch - this option nests differently at this depth
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** The first real page slug in the whole site, root navigation pattern
|
|
1043
|
+
* included - used to redirect `/` somewhere sensible when nothing in
|
|
1044
|
+
* the navigation happens to be a page literally named "index" (the
|
|
1045
|
+
* usual "docs/index.mdx is the homepage" convention). Mirrors
|
|
1046
|
+
* firstSlugOf()'s per-container descent, plus the flat-array root case
|
|
1047
|
+
* firstSlugOf() alone can't handle since a bare array isn't a Container. */
|
|
1048
|
+
export function firstSlugOfNavigation(navigation: NavigationConfig): string | null {
|
|
1049
|
+
if (Array.isArray(navigation)) return flattenNav(navigation)[0]?.slug ?? null;
|
|
1050
|
+
return firstSlugOf(navigation as Container);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** Whether `slug` is reachable anywhere underneath a container, however
|
|
1054
|
+
* deep - used for computing active state on nodes that aren't part of
|
|
1055
|
+
* the active Section's own `path` (global dropdown entries). */
|
|
1056
|
+
export function containerContainsSlug(node: Container, slug: string): boolean {
|
|
1057
|
+
if ('pages' in node) return flattenNav(node.pages).some((entry) => entry.slug === slug);
|
|
1058
|
+
if ('href' in node) return false;
|
|
1059
|
+
if ('tabs' in node) return node.tabs.some((t) => containerContainsSlug(t, slug));
|
|
1060
|
+
if ('versions' in node) return node.versions.some((v) => containerContainsSlug(v, slug));
|
|
1061
|
+
if ('languages' in node) return node.languages.some((l) => containerContainsSlug(l, slug));
|
|
1062
|
+
if ('dropdowns' in node) return node.dropdowns.some((d) => containerContainsSlug(d, slug));
|
|
1063
|
+
if ('products' in node) return node.products.some((p) => containerContainsSlug(p, slug));
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function labelOf(item: NamedContainer): string {
|
|
1068
|
+
if ('tab' in item) return item.tab;
|
|
1069
|
+
if ('version' in item) return item.label ?? item.version;
|
|
1070
|
+
if ('language' in item) return item.label ?? item.language;
|
|
1071
|
+
if ('dropdown' in item) return item.dropdown;
|
|
1072
|
+
return item.product;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function iconOf(item: NamedContainer): string | undefined {
|
|
1076
|
+
return (item as { icon?: string }).icon;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// --- Topbar selectors -------------------------------------------------
|
|
1080
|
+
//
|
|
1081
|
+
// One Selector per PathSegment on the active Section's path. Tabs render
|
|
1082
|
+
// as the horizontal pill bar (all options always visible, exactly one
|
|
1083
|
+
// current); versions/languages/products, and dropdowns used as a nested
|
|
1084
|
+
// path segment, render as a single switcher control instead - the
|
|
1085
|
+
// trigger shows the *current* option, its menu lists the alternatives.
|
|
1086
|
+
// See BaseLayout.astro for the actual markup per kind.
|
|
1087
|
+
|
|
1088
|
+
export interface SelectorOption {
|
|
1089
|
+
label: string;
|
|
1090
|
+
icon?: string;
|
|
1091
|
+
tag?: string;
|
|
1092
|
+
href: string;
|
|
1093
|
+
active: boolean;
|
|
1094
|
+
// Set when this option's own container is a `dropdowns` list (e.g. a
|
|
1095
|
+
// tab whose content is `dropdowns` instead of `pages`) - the tab pill
|
|
1096
|
+
// itself becomes a dropdown-trigger showing these as its menu, rather
|
|
1097
|
+
// than a separate dropdown control rendered alongside it. Only ever
|
|
1098
|
+
// populated one level deep (a dropdown option doesn't itself get a
|
|
1099
|
+
// nested `dropdown` - matches the one level of "a tab/dropdown owns a
|
|
1100
|
+
// dropdowns list" this is meant to cover).
|
|
1101
|
+
dropdown?: SelectorOption[];
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export interface Selector {
|
|
1105
|
+
kind: PathSegment['kind'];
|
|
1106
|
+
options: SelectorOption[];
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** An option's own `dropdowns` menu, if it has one (see SelectorOption.dropdown
|
|
1110
|
+
* above) - `activeSegment` is the *next* PathSegment after this option's own
|
|
1111
|
+
* segment, only passed when this option is the active one on its level, so a
|
|
1112
|
+
* sibling option that also happens to own `dropdowns` doesn't spuriously mark
|
|
1113
|
+
* one of its entries active just because some *other* option is currently
|
|
1114
|
+
* selected. */
|
|
1115
|
+
function dropdownMenuOf(
|
|
1116
|
+
node: NavChildren,
|
|
1117
|
+
hrefForSlug: (slug: string) => string,
|
|
1118
|
+
activeSegment: PathSegment | undefined
|
|
1119
|
+
): SelectorOption[] | undefined {
|
|
1120
|
+
if (!('dropdowns' in node)) return undefined;
|
|
1121
|
+
return node.dropdowns.map((d, i) => ({
|
|
1122
|
+
label: d.dropdown,
|
|
1123
|
+
icon: iconOf(d),
|
|
1124
|
+
href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
|
|
1125
|
+
active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
|
|
1126
|
+
}));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** `activePagePosition` is the reader's current page's own index within
|
|
1130
|
+
* its Section's flattened pages list (-1 if it can't be found there,
|
|
1131
|
+
* which just disables the position-preserving behavior below) - see
|
|
1132
|
+
* [...slug].astro for how it's computed. Only version/language
|
|
1133
|
+
* switchers try to preserve position across options; tabs/dropdowns/
|
|
1134
|
+
* products keep linking to firstSlugOf() unconditionally, since those
|
|
1135
|
+
* represent genuinely different content (an "API Reference" tab isn't
|
|
1136
|
+
* "the same page" as a "Guides" tab just because they're both first),
|
|
1137
|
+
* unlike a version/language of what's meant to be the same docs. */
|
|
1138
|
+
export function buildSelectors(
|
|
1139
|
+
path: PathSegment[],
|
|
1140
|
+
hrefForSlug: (slug: string) => string,
|
|
1141
|
+
activePagePosition: number = -1
|
|
1142
|
+
): Selector[] {
|
|
1143
|
+
return path.map((segment, segIndex): Selector => {
|
|
1144
|
+
const preservesPosition =
|
|
1145
|
+
(segment.kind === 'version' || segment.kind === 'language') && activePagePosition >= 0;
|
|
1146
|
+
const remainingPath = path.slice(segIndex + 1);
|
|
1147
|
+
return {
|
|
1148
|
+
kind: segment.kind,
|
|
1149
|
+
options: (segment.items as NamedContainer[]).map((item, i) => {
|
|
1150
|
+
const isActive = i === segment.index;
|
|
1151
|
+
const equivalentSlug = preservesPosition
|
|
1152
|
+
? equivalentPageIn(item as Container, remainingPath, activePagePosition)
|
|
1153
|
+
: null;
|
|
1154
|
+
const targetSlug = equivalentSlug ?? firstSlugOf(item as Container) ?? 'index';
|
|
1155
|
+
return {
|
|
1156
|
+
label: labelOf(item),
|
|
1157
|
+
icon: iconOf(item),
|
|
1158
|
+
tag: 'tag' in item ? item.tag : undefined,
|
|
1159
|
+
href: 'href' in item ? item.href : hrefForSlug(targetSlug),
|
|
1160
|
+
active: isActive,
|
|
1161
|
+
dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
|
|
1162
|
+
};
|
|
1163
|
+
}),
|
|
1164
|
+
};
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// --- Global dropdowns ---------------------------------------------------
|
|
1169
|
+
//
|
|
1170
|
+
// Unlike path-segment selectors, global dropdowns aren't a mutually
|
|
1171
|
+
// exclusive "pick one" choice - `global.dropdowns` is an array where
|
|
1172
|
+
// *every* entry renders as its own always-visible trigger button
|
|
1173
|
+
// simultaneously (Mintlify's anchors work the same way). A trigger's
|
|
1174
|
+
// menu lists its own direct pages as quick links; a bare-href entry is
|
|
1175
|
+
// just a plain link with no menu; a deeply-nested entry (tabs/versions/
|
|
1176
|
+
// etc. instead of direct pages) falls back to linking straight to its
|
|
1177
|
+
// first page, since building a rich flyout for that combination isn't
|
|
1178
|
+
// worth the complexity for what's meant to be a quick-links affordance.
|
|
1179
|
+
|
|
1180
|
+
export interface GlobalDropdownView {
|
|
1181
|
+
label: string;
|
|
1182
|
+
icon?: string;
|
|
1183
|
+
href: string | null;
|
|
1184
|
+
items: SelectorOption[];
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
export function buildGlobalDropdowns(
|
|
1188
|
+
dropdowns: DropdownItem[],
|
|
1189
|
+
currentSlug: string,
|
|
1190
|
+
titleForSlug: (slug: string) => string,
|
|
1191
|
+
hrefForSlug: (slug: string) => string
|
|
1192
|
+
): GlobalDropdownView[] {
|
|
1193
|
+
return dropdowns.map((d): GlobalDropdownView => {
|
|
1194
|
+
if ('href' in d) {
|
|
1195
|
+
return { label: d.dropdown, icon: d.icon, href: d.href, items: [] };
|
|
1196
|
+
}
|
|
1197
|
+
if ('pages' in d) {
|
|
1198
|
+
const items = flattenNav(d.pages).map((entry) => ({
|
|
1199
|
+
label: titleForSlug(entry.slug),
|
|
1200
|
+
href: hrefForSlug(entry.slug),
|
|
1201
|
+
active: entry.slug === currentSlug,
|
|
1202
|
+
}));
|
|
1203
|
+
return { label: d.dropdown, icon: d.icon, href: null, items };
|
|
1204
|
+
}
|
|
1205
|
+
const slug = firstSlugOf(d);
|
|
1206
|
+
return { label: d.dropdown, icon: d.icon, href: slug ? hrefForSlug(slug) : '#', items: [] };
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// --- Sidebar (recursive) -------------------------------------------------
|
|
1211
|
+
|
|
1212
|
+
// A group node's `pageSlug` is the page its own label links to (null if
|
|
1213
|
+
// it's a pure disclosure/label with no page of its own - the group only
|
|
1214
|
+
// groups). NavTree.astro renders depth-0 groups as static, non-collapsible
|
|
1215
|
+
// section titles (linked if `pageSlug` is set, plain text otherwise) and
|
|
1216
|
+
// every deeper group as a collapsible row styled like a page item, with a
|
|
1217
|
+
// chevron, defaulting open when it contains the active page - see
|
|
1218
|
+
// navTreeContainsSlug() below.
|
|
1219
|
+
export type NavTreeNode =
|
|
1220
|
+
| { kind: 'page'; slug: string; title: string; method: string | null }
|
|
1221
|
+
| { kind: 'group'; label: string; pageSlug: string | null; children: NavTreeNode[] }
|
|
1222
|
+
| { kind: 'link'; label: string; href: string };
|
|
1223
|
+
|
|
1224
|
+
/** Builds the sidebar's view model, preserving group nesting depth.
|
|
1225
|
+
* `methodForSlug` is optional (most sites have no OpenAPI pages at all)
|
|
1226
|
+
* and, when given, returns the HTTP method to badge a page with in the
|
|
1227
|
+
* sidebar (e.g. "GET") or null for an ordinary page - see
|
|
1228
|
+
* NavTree.astro for how that badge renders. */
|
|
1229
|
+
export function buildNavTree(
|
|
1230
|
+
navigation: NavItem[],
|
|
1231
|
+
titleForSlug: (slug: string) => string,
|
|
1232
|
+
methodForSlug?: (slug: string) => string | null
|
|
1233
|
+
): NavTreeNode[] {
|
|
1234
|
+
return navigation.map((item): NavTreeNode => {
|
|
1235
|
+
if (typeof item === 'string') {
|
|
1236
|
+
return {
|
|
1237
|
+
kind: 'page',
|
|
1238
|
+
slug: item,
|
|
1239
|
+
title: titleForSlug(item),
|
|
1240
|
+
method: methodForSlug?.(item) ?? null,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
if ('href' in item) {
|
|
1244
|
+
return { kind: 'link', label: item.label, href: item.href };
|
|
1245
|
+
}
|
|
1246
|
+
// loadDocsConfig() always expands `{ group, openapi }` shorthand into
|
|
1247
|
+
// a real `{ group, pages }` before anything reaches here (see
|
|
1248
|
+
// expandOpenApiInNavigation() in the OpenAPI section above) - this is
|
|
1249
|
+
// just a defensive no-op for the shouldn't-happen case of an
|
|
1250
|
+
// unexpanded node reaching the sidebar builder.
|
|
1251
|
+
if ('openapi' in item) {
|
|
1252
|
+
return { kind: 'group', label: item.group, pageSlug: null, children: [] };
|
|
1253
|
+
}
|
|
1254
|
+
return {
|
|
1255
|
+
kind: 'group',
|
|
1256
|
+
label: item.group,
|
|
1257
|
+
pageSlug: item.page ?? null,
|
|
1258
|
+
children: buildNavTree(item.pages, titleForSlug, methodForSlug),
|
|
1259
|
+
};
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Whether `slug` is the group's own attached page, or belongs to any page/group nested inside it. */
|
|
1264
|
+
export function navTreeContainsSlug(nodes: NavTreeNode[], slug: string): boolean {
|
|
1265
|
+
return nodes.some((node) => {
|
|
1266
|
+
if (node.kind === 'page') return node.slug === slug;
|
|
1267
|
+
if (node.kind === 'link') return false;
|
|
1268
|
+
return node.pageSlug === slug || navTreeContainsSlug(node.children, slug);
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
export interface BreadcrumbCrumb {
|
|
1273
|
+
label: string;
|
|
1274
|
+
href: string | null;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** The chain of ancestor group labels (root first) that `slug` is nested
|
|
1278
|
+
* under within `nodes` - empty when the page sits at the top level of its
|
|
1279
|
+
* Section with no enclosing group at all, in which case the caller should
|
|
1280
|
+
* skip rendering breadcrumbs entirely (there'd be nothing to show but the
|
|
1281
|
+
* fixed home icon Breadcrumbs.astro always renders first).
|
|
1282
|
+
*
|
|
1283
|
+
* Deliberately never includes the current page itself - only the groups
|
|
1284
|
+
* it's nested under (that's already the <h1> right below, repeating it
|
|
1285
|
+
* in the breadcrumb trail would just be noise). A group whose own
|
|
1286
|
+
* attached page (`pageSlug`, see NavTreeNode) *is* `slug` is excluded
|
|
1287
|
+
* from its own trail for the same reason - you're looking at that page,
|
|
1288
|
+
* it doesn't need to also list itself as its own ancestor. A group only
|
|
1289
|
+
* appears here when `slug` is nested *inside* it (its own page, if any,
|
|
1290
|
+
* links to that group's landing page - `href: null` for a label-only
|
|
1291
|
+
* group with no page of its own to link to). */
|
|
1292
|
+
export function ancestorGroupsForSlug(
|
|
1293
|
+
nodes: NavTreeNode[],
|
|
1294
|
+
slug: string,
|
|
1295
|
+
hrefForSlug: (slug: string) => string
|
|
1296
|
+
): BreadcrumbCrumb[] {
|
|
1297
|
+
for (const node of nodes) {
|
|
1298
|
+
if (node.kind !== 'group') continue;
|
|
1299
|
+
if (node.pageSlug === slug) return [];
|
|
1300
|
+
if (navTreeContainsSlug(node.children, slug)) {
|
|
1301
|
+
const crumb: BreadcrumbCrumb = { label: node.label, href: node.pageSlug ? hrefForSlug(node.pageSlug) : null };
|
|
1302
|
+
return [crumb, ...ancestorGroupsForSlug(node.children, slug, hrefForSlug)];
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return [];
|
|
1306
|
+
}
|