Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ No API definition found. Expected one of the following:
- An api section in generators.yml pointing to your API spec(s)
- A definition/ directory with Fern Definition files
For more information, see https://buildwithfern.com/learn/api-definition/introduction/what-is-the-fern-folder
All checks passed"
Found 0 errors and 1 warning in __ELAPSED__ seconds. Run fern check --warnings to print out the warnings not shown."
`;

exports[`validate > no-api 1`] = `"Missing file: api.yml"`;
Expand Down
81 changes: 80 additions & 1 deletion packages/cli/workspace/loader/src/loadDocsWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,82 @@ import yaml from "js-yaml";
import * as DocsYmlJsonSchema from "./docs-yml.schema.json";
import { DocsWorkspace } from "./types/Workspace.js";

function formatNavigationConfigError({
error,
value,
defaultMessage = "Unknown error"
}: {
error: validateAgainstJsonSchema.JsonSchemaError | undefined;
value: unknown;
defaultMessage?: string;
}): string {
const message = error?.message ?? defaultMessage;
const breadcrumb = error?.instancePath != null ? getNavigationBreadcrumb(value, error.instancePath) : undefined;
return `${message}${breadcrumb != null ? ` (${breadcrumb})` : ""}`;
}

function getNavigationBreadcrumb(value: unknown, instancePath: string): string | undefined {
const segments = instancePath
.split("/")
.filter((part) => part.length > 0)
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));

const navigationIndex = segments.indexOf("navigation");
if (navigationIndex === -1) {
return undefined;
}

const breadcrumb: string[] = [];
let current: unknown = value;

for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (segment == null) {
continue;
}
if (isRecord(current) && i > navigationIndex && /^\d+$/.test(segments[i - 1] ?? "")) {
const label = getNavigationLabel(current);
if (label != null) {
breadcrumb.push(label);
}
}
current = getChild(current, segment);
}

if (isRecord(current)) {
const label = getNavigationLabel(current);
if (label != null) {
breadcrumb.push(label);
}
}

return breadcrumb.length > 0 ? `/${breadcrumb.join("/")}/` : undefined;
}

function getChild(value: unknown, segment: string): unknown {
if (Array.isArray(value) && /^\d+$/.test(segment)) {
return value[Number(segment)];
}
if (isRecord(value)) {
return value[segment];
}
return undefined;
}

function getNavigationLabel(value: Record<string, unknown>): string | undefined {
for (const key of ["tab", "section", "page", "title", "api", "changelog", "link"]) {
const label = value[key];
if (typeof label === "string" && label.length > 0) {
return label;
}
}
return undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value != null && !Array.isArray(value);
}

export async function loadDocsWorkspace({
fernDirectory,
context
Expand Down Expand Up @@ -109,7 +185,10 @@ export async function loadRawDocsConfiguration({
}
} else {
throw new CliError({
message: `Failed to parse docs.yml:\n${result.error?.message ?? "Unknown error"}`,
message: `Failed to parse docs.yml:\n${formatNavigationConfigError({
error: result.error,
value: contentsJson
})}`,
code: CliError.Code.ParseError
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";

import { formatNavigationConfigError } from "../docsAst/formatNavigationConfigError.js";

describe("formatNavigationConfigError", () => {
it("adds tab and section names for nested navigation errors", () => {
const message = formatNavigationConfigError({
value: {
navigation: [
{
tab: "functions",
layout: [
{
page: "Overview",
path: "overview.mdx"
},
{
section: "Regular functions",
"skip-slug": true,
collapsed: true,
contents: []
}
]
}
]
},
error: {
keyword: "anyOf",
instancePath: "/navigation/0/layout/1",
schemaPath: "#/definitions/docs.NavigationItem/anyOf",
params: {},
message: "Invalid object at path $.navigation[0].layout[1]: does not match any allowed schema"
}
});

expect(message).toBe(
"Invalid object at path $.navigation[0].layout[1]: does not match any allowed schema at /navigation/0/layout/1 (/functions/Regular functions/)"
);
});

it("does not add breadcrumbs for non-navigation errors", () => {
const message = formatNavigationConfigError({
value: {
navigation: []
},
error: {
keyword: "type",
instancePath: "/colors/accent",
schemaPath: "#/properties/colors",
params: {},
message: "Incorrect type at path $.colors.accent"
}
});

expect(message).toBe("Incorrect type at path $.colors.accent at /colors/accent");
});

it("adds nested page names for deeply nested navigation errors", () => {
const message = formatNavigationConfigError({
value: {
navigation: [
{
tab: "functions",
layout: [
{
section: "Regular functions",
contents: [
{
page: "Create user",
unknown: true
}
]
}
]
}
]
},
error: {
keyword: "anyOf",
instancePath: "/navigation/0/layout/0/contents/0",
schemaPath: "#/definitions/docs.NavigationItem/anyOf",
params: {},
message:
"Invalid object at path $.navigation[0].layout[0].contents[0]: does not match any allowed schema"
}
});

expect(message).toBe(
"Invalid object at path $.navigation[0].layout[0].contents[0]: does not match any allowed schema at /navigation/0/layout/0/contents/0 (/functions/Regular functions/Create user/)"
);
});

it("does not include the root docs title in navigation breadcrumbs", () => {
const message = formatNavigationConfigError({
value: {
title: "My API",
navigation: [
{
tab: "functions",
layout: [
{
page: "Overview",
unknown: true
}
]
}
]
},
error: {
keyword: "anyOf",
instancePath: "/navigation/0/layout/0",
schemaPath: "#/definitions/docs.NavigationItem/anyOf",
params: {},
message: "Invalid object at path $.navigation[0].layout[0]: does not match any allowed schema"
}
});

expect(message).toBe(
"Invalid object at path $.navigation[0].layout[0]: does not match any allowed schema at /navigation/0/layout/0 (/functions/Overview/)"
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { type validateAgainstJsonSchema } from "@fern-api/core-utils";

export function formatNavigationConfigError({
error,
value,
defaultMessage = "Failed to parse because JSON schema validation failed"
}: {
error: validateAgainstJsonSchema.JsonSchemaError | undefined;
value: unknown;
defaultMessage?: string;
}): string {
const message = error?.message ?? defaultMessage;
const path = error?.instancePath ? ` at ${error.instancePath}` : "";
const breadcrumb = error?.instancePath != null ? getNavigationBreadcrumb(value, error.instancePath) : undefined;
return `${message}${path}${breadcrumb != null ? ` (${breadcrumb})` : ""}`;
}

function getNavigationBreadcrumb(value: unknown, instancePath: string): string | undefined {
const segments = parseJsonPointer(instancePath);
const navigationIndex = segments.indexOf("navigation");
if (navigationIndex === -1) {
return undefined;
}

const breadcrumb: string[] = [];
let current: unknown = value;

for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (segment == null) {
continue;
}
if (isRecord(current) && i > navigationIndex) {
const label = getNavigationLabel(current);
if (label != null && shouldIncludeLabelForSegment(segments[i - 1])) {
Comment thread
shubhamsinnh marked this conversation as resolved.
breadcrumb.push(label);
}
}

current = getChild(current, segment);
}

if (isRecord(current)) {
const label = getNavigationLabel(current);
if (label != null) {
breadcrumb.push(label);
}
}

if (breadcrumb.length === 0) {
return undefined;
}

return `/${breadcrumb.join("/")}/`;
}

function shouldIncludeLabelForSegment(segment: string | undefined): boolean {
return segment == null || /^\d+$/.test(segment);
}

function parseJsonPointer(instancePath: string): string[] {
return instancePath
.split("/")
.filter((part) => part.length > 0)
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
}

function getChild(value: unknown, segment: string): unknown {
if (Array.isArray(value) && /^\d+$/.test(segment)) {
return value[Number(segment)];
}
if (isRecord(value)) {
return value[segment];
}
return undefined;
}

function getNavigationLabel(value: Record<string, unknown>): string | undefined {
for (const key of ["tab", "section", "page", "title", "api", "changelog", "link"]) {
const label = value[key];
if (typeof label === "string" && label.length > 0) {
return label;
}
}
return undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value != null && !Array.isArray(value);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { docsYml } from "@fern-api/configuration-loader";
import { sanitizeNullValues, validateAgainstJsonSchema } from "@fern-api/core-utils";

import { formatNavigationConfigError } from "./formatNavigationConfigError.js";
import * as DocsYmlJsonSchema from "./products-yml.schema.json";

export type ProductParseResult = ProductFileSuccessParseResult | ProductFileFailureParseResult;
Expand Down Expand Up @@ -29,9 +30,8 @@ export async function validateProductConfigFileSchema({ value }: { value: unknow
};
}

const path = result.error?.instancePath ? ` at ${result?.error.instancePath}` : "";
return {
type: "failure",
message: `${result.error?.message ?? "Failed to parse because JSON schema validation failed"}${path}`
message: formatNavigationConfigError({ error: result.error, value })
};
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { docsYml } from "@fern-api/configuration-loader";
import { sanitizeNullValues, validateAgainstJsonSchema } from "@fern-api/core-utils";

import { formatNavigationConfigError } from "./formatNavigationConfigError.js";
import * as DocsYmlJsonSchema from "./versions-yml.schema.json";

export type VersionParseResult = VersionFileSuccessParseResult | VersionFileFailureParseResult;
Expand Down Expand Up @@ -29,9 +30,8 @@ export async function validateVersionConfigFileSchema({ value }: { value: unknow
};
}

const path = result.error?.instancePath ? ` at ${result?.error.instancePath}` : "";
return {
type: "failure",
message: `${result.error?.message ?? "Failed to parse because JSON schema validation failed"}${path}`
message: formatNavigationConfigError({ error: result.error, value })
};
}
Loading
Loading