Skip to content

Commit 0b519aa

Browse files
kafkasdevin-ai-integration[bot]thesandlord
authored
feat(cli): add collapsible + collapsed-by-default to docs.yml navigation (#12726)
* Update docs-yml definition * Update cli types * Update navigationUtils * Update DocsDefinitionResolver * Update parsing logic * Add tests for new fields * Update json schemas * Add changelog entry * Mark collapsed attrs as availability: deprecated in Fern definition Co-Authored-By: Sandeep Dinesh <sandeep@buildwithfern.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Sandeep Dinesh <sandeep@buildwithfern.com>
1 parent d29ee4a commit 0b519aa

19 files changed

Lines changed: 470 additions & 5 deletions

File tree

docs-yml.schema.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,6 +1497,26 @@
14971497
}
14981498
]
14991499
},
1500+
"collapsible": {
1501+
"oneOf": [
1502+
{
1503+
"type": "boolean"
1504+
},
1505+
{
1506+
"type": "null"
1507+
}
1508+
]
1509+
},
1510+
"collapsed-by-default": {
1511+
"oneOf": [
1512+
{
1513+
"type": "boolean"
1514+
},
1515+
{
1516+
"type": "null"
1517+
}
1518+
]
1519+
},
15001520
"slug": {
15011521
"oneOf": [
15021522
{
@@ -2776,6 +2796,26 @@
27762796
}
27772797
]
27782798
},
2799+
"collapsible": {
2800+
"oneOf": [
2801+
{
2802+
"type": "boolean"
2803+
},
2804+
{
2805+
"type": "null"
2806+
}
2807+
]
2808+
},
2809+
"collapsed-by-default": {
2810+
"oneOf": [
2811+
{
2812+
"type": "boolean"
2813+
},
2814+
{
2815+
"type": "null"
2816+
}
2817+
]
2818+
},
27792819
"availability": {
27802820
"oneOf": [
27812821
{

fern/apis/docs-yml/definition/docs.yml

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,7 +1093,19 @@ types:
10931093
docs: |
10941094
The relative path to the markdown file that will be displayed when the section is clicked.
10951095
contents: list<NavigationItem>
1096-
collapsed: optional<boolean>
1096+
collapsed:
1097+
type: optional<boolean>
1098+
docs: |
1099+
Deprecated. Use `collapsible` and `collapsed-by-default` instead.
1100+
availability: deprecated
1101+
collapsible:
1102+
type: optional<boolean>
1103+
docs: |
1104+
Whether the section can be expanded/collapsed by the user.
1105+
collapsed-by-default:
1106+
type: optional<boolean>
1107+
docs: |
1108+
Whether the section starts collapsed. Only meaningful when `collapsible` is true. Defaults to false (starts open).
10971109
slug: optional<string>
10981110
icon: optional<string>
10991111
hidden: optional<boolean>
@@ -1119,7 +1131,19 @@ types:
11191131
icon: optional<string>
11201132
hidden: optional<boolean>
11211133
skip-slug: optional<boolean>
1122-
collapsed: optional<boolean>
1134+
collapsed:
1135+
type: optional<boolean>
1136+
docs: |
1137+
Deprecated. Use `collapsible` and `collapsed-by-default` instead.
1138+
availability: deprecated
1139+
collapsible:
1140+
type: optional<boolean>
1141+
docs: |
1142+
Whether the section can be expanded/collapsed by the user.
1143+
collapsed-by-default:
1144+
type: optional<boolean>
1145+
docs: |
1146+
Whether the section starts collapsed. Only meaningful when `collapsible` is true. Defaults to false (starts open).
11231147
availability: optional<Availability>
11241148

11251149
TitleSource:
@@ -1156,7 +1180,9 @@ types:
11561180
type: optional<list<ApiReferenceLayoutItem>>
11571181
docs: |
11581182
Advanced usage: when specified, this object will be used to customize the order that your API endpoints are displayed in the docs site, including subpackages, and additional markdown pages (to be rendered in between API endpoints). If not specified, the order will be inferred from the OpenAPI Spec or Fern Definition.
1159-
collapsed: optional<boolean>
1183+
collapsed:
1184+
type: optional<boolean>
1185+
availability: deprecated
11601186
icon: optional<string>
11611187
slug: optional<string>
11621188
hidden: optional<boolean>

packages/cli/cli/versions.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
2+
3+
- version: 3.87.0
4+
changelogEntry:
5+
- summary: |
6+
Added `collapsible` and `collapsed-by-default` options for `docs.yml` navigation sections/folders, preserving the legacy `collapsed` behavior while adding validation to prevent invalid configurations.
7+
type: feat
8+
createdAt: "2026-02-24"
9+
irVersion: 65
10+
211
- version: 3.86.1
312
changelogEntry:
413
- summary: |

packages/cli/config/src/schemas/docs/navigation/FolderConfigurationSchema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export const FolderConfigurationSchema = z.object({
1616
icon: z.string().optional(),
1717
hidden: z.boolean().optional(),
1818
collapsed: z.boolean().optional(),
19+
collapsible: z.boolean().optional(),
20+
collapsedByDefault: z.boolean().optional(),
1921
path: z.string().optional(),
2022
// WithPermissions
2123
viewers: RoleSchema.optional(),

packages/cli/config/src/schemas/docs/navigation/SectionConfigurationSchema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const SectionConfigurationSchema = z.object({
2929
hidden: z.boolean().optional(),
3030
skipSlug: z.boolean().optional(),
3131
collapsed: z.boolean().optional(),
32+
collapsible: z.boolean().optional(),
33+
collapsedByDefault: z.boolean().optional(),
3234
flattened: z.boolean().optional(),
3335
path: z.string().optional(),
3436
availability: AvailabilitySchema.optional(),
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { docsYml } from "@fern-api/configuration";
2+
import { AbsoluteFilePath } from "@fern-api/fs-utils";
3+
import { createMockTaskContext, FernCliError } from "@fern-api/task-context";
4+
import { describe, expect, it } from "vitest";
5+
6+
import { parseDocsConfiguration } from "../parseDocsConfiguration.js";
7+
8+
describe("docs.yml navigation collapsible config", () => {
9+
it("should throw if collapsed-by-default is set without collapsible: true", async () => {
10+
const context = createMockTaskContext();
11+
12+
const rawDocsConfiguration: docsYml.RawSchemas.DocsConfiguration = {
13+
instances: [],
14+
title: "Test",
15+
navigation: [
16+
{
17+
section: "Section",
18+
contents: [],
19+
collapsedByDefault: true
20+
}
21+
]
22+
};
23+
24+
await expect(
25+
parseDocsConfiguration({
26+
rawDocsConfiguration,
27+
// These paths shouldn't be used by this test case.
28+
absolutePathToFernFolder: AbsoluteFilePath.of("/tmp"),
29+
absoluteFilepathToDocsConfig: AbsoluteFilePath.of("/tmp/docs.yml"),
30+
context
31+
})
32+
).rejects.toBeInstanceOf(FernCliError);
33+
});
34+
35+
it("should throw if collapsible is used alongside deprecated collapsed", async () => {
36+
const context = createMockTaskContext();
37+
38+
const rawDocsConfiguration: docsYml.RawSchemas.DocsConfiguration = {
39+
instances: [],
40+
title: "Test",
41+
navigation: [
42+
{
43+
section: "Section",
44+
contents: [],
45+
collapsible: true,
46+
collapsed: true
47+
}
48+
]
49+
};
50+
51+
await expect(
52+
parseDocsConfiguration({
53+
rawDocsConfiguration,
54+
absolutePathToFernFolder: AbsoluteFilePath.of("/tmp"),
55+
absoluteFilepathToDocsConfig: AbsoluteFilePath.of("/tmp/docs.yml"),
56+
context
57+
})
58+
).rejects.toBeInstanceOf(FernCliError);
59+
});
60+
61+
it("should pass through collapsible + collapsedByDefault on sections", async () => {
62+
const context = createMockTaskContext();
63+
64+
const rawDocsConfiguration: docsYml.RawSchemas.DocsConfiguration = {
65+
instances: [],
66+
title: "Test",
67+
navigation: [
68+
{
69+
section: "Section",
70+
contents: [],
71+
collapsible: true,
72+
collapsedByDefault: true
73+
}
74+
]
75+
};
76+
77+
const parsed = await parseDocsConfiguration({
78+
rawDocsConfiguration,
79+
absolutePathToFernFolder: AbsoluteFilePath.of("/tmp"),
80+
absoluteFilepathToDocsConfig: AbsoluteFilePath.of("/tmp/docs.yml"),
81+
context
82+
});
83+
84+
expect(parsed.navigation).toMatchObject({
85+
type: "untabbed",
86+
items: [
87+
{
88+
type: "section",
89+
title: "Section",
90+
collapsible: true,
91+
collapsedByDefault: true
92+
}
93+
]
94+
});
95+
});
96+
});

packages/cli/configuration-loader/src/docs-yml/navigationUtils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ export async function buildNavigationForDirectory({
177177
icon: undefined,
178178
contents: filteredContents,
179179
collapsed: undefined,
180+
collapsible: undefined,
181+
collapsedByDefault: undefined,
180182
hidden: undefined,
181183
skipUrlSlug: false,
182184
overviewAbsolutePath: indexPage?.type === "page" ? indexPage.absolutePath : undefined,

packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,14 @@ async function expandFolderConfiguration({
10071007
context.failAndThrow(`Folder not found: ${rawConfig.folder}`);
10081008
}
10091009

1010+
validateCollapsibleConfig({
1011+
context,
1012+
sectionTitle: rawConfig.folder,
1013+
collapsed: rawConfig.collapsed ?? undefined,
1014+
collapsible: rawConfig.collapsible ?? undefined,
1015+
collapsedByDefault: rawConfig.collapsedByDefault ?? undefined
1016+
});
1017+
10101018
const effectiveTitleSource = rawConfig.titleSource ?? folderTitleSource;
10111019

10121020
const contents = await buildNavigationForDirectory({
@@ -1039,6 +1047,8 @@ async function expandFolderConfiguration({
10391047
contents: filteredContents,
10401048
slug,
10411049
collapsed: rawConfig.collapsed ?? undefined,
1050+
collapsible: rawConfig.collapsible ?? undefined,
1051+
collapsedByDefault: rawConfig.collapsedByDefault ?? undefined,
10421052
hidden: rawConfig.hidden ?? undefined,
10431053
skipUrlSlug: rawConfig.skipSlug ?? false,
10441054
overviewAbsolutePath: indexPage?.type === "page" ? indexPage.absolutePath : undefined,
@@ -1066,6 +1076,13 @@ async function convertNavigationItem({
10661076
return parsePageConfig(rawConfig, absolutePathToConfig);
10671077
}
10681078
if (isRawSectionConfig(rawConfig)) {
1079+
validateCollapsibleConfig({
1080+
context,
1081+
sectionTitle: rawConfig.section,
1082+
collapsed: rawConfig.collapsed ?? undefined,
1083+
collapsible: rawConfig.collapsible ?? undefined,
1084+
collapsedByDefault: rawConfig.collapsedByDefault ?? undefined
1085+
});
10691086
return {
10701087
type: "section",
10711088
title: rawConfig.section,
@@ -1083,6 +1100,8 @@ async function convertNavigationItem({
10831100
),
10841101
slug: rawConfig.slug ?? undefined,
10851102
collapsed: rawConfig.collapsed ?? undefined,
1103+
collapsible: rawConfig.collapsible ?? undefined,
1104+
collapsedByDefault: rawConfig.collapsedByDefault ?? undefined,
10861105
hidden: rawConfig.hidden ?? undefined,
10871106
skipUrlSlug: rawConfig.skipSlug ?? false,
10881107
overviewAbsolutePath: resolveFilepath(rawConfig.path, absolutePathToConfig),
@@ -1636,3 +1655,31 @@ export function parseAudiences(raw: string | string[] | undefined): string[] | u
16361655

16371656
return raw;
16381657
}
1658+
1659+
function validateCollapsibleConfig({
1660+
context,
1661+
sectionTitle,
1662+
collapsed,
1663+
collapsible,
1664+
collapsedByDefault
1665+
}: {
1666+
context: TaskContext;
1667+
sectionTitle: string;
1668+
collapsed: boolean | undefined;
1669+
collapsible: boolean | undefined;
1670+
collapsedByDefault: boolean | undefined;
1671+
}): void {
1672+
if (collapsible != null && collapsed != null) {
1673+
context.failAndThrow(
1674+
`Section "${sectionTitle}": cannot use both "collapsible" and the deprecated "collapsed" property. ` +
1675+
`Please use "collapsible" and "collapsed-by-default" instead.`
1676+
);
1677+
}
1678+
1679+
if (collapsedByDefault != null && collapsible !== true) {
1680+
context.failAndThrow(
1681+
`Section "${sectionTitle}": "collapsed-by-default" requires "collapsible: true". ` +
1682+
`"collapsed-by-default" has no effect on a non-collapsible section.`
1683+
);
1684+
}
1685+
}

packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ export declare namespace DocsNavigationItem {
324324
icon: string | AbsoluteFilePath | undefined;
325325
contents: DocsNavigationItem[];
326326
collapsed: boolean | undefined;
327+
collapsible: boolean | undefined;
328+
collapsedByDefault: boolean | undefined;
327329
slug: string | undefined;
328330
hidden: boolean | undefined;
329331
skipUrlSlug: boolean | undefined;

packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/FolderConfiguration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export interface FolderConfiguration extends FernDocsConfig.WithPermissions, Fer
1313
icon?: string;
1414
hidden?: boolean;
1515
skipSlug?: boolean;
16+
/** Deprecated. Use `collapsible` and `collapsed-by-default` instead. */
1617
collapsed?: boolean;
18+
/** Whether the section can be expanded/collapsed by the user. */
19+
collapsible?: boolean;
20+
/** Whether the section starts collapsed. Only meaningful when `collapsible` is true. Defaults to false (starts open). */
21+
collapsedByDefault?: boolean;
1722
availability?: FernDocsConfig.Availability;
1823
}

0 commit comments

Comments
 (0)