Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 15 additions & 4 deletions docs-yml.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,7 @@
"redirects": {
"oneOf": [
{
"type": "array",
"items": {
"$ref": "#/definitions/docs.RedirectConfig"
}
"$ref": "#/definitions/docs.RedirectsConfiguration"
},
{
"type": "null"
Expand Down Expand Up @@ -5236,6 +5233,20 @@
"additionalProperties": false,
"description": "The `redirects` object allows you to redirect traffic from one path to another. You can redirect exact paths or use dynamic patterns with regex parameters like `:slug` to handle bulk redirects. You can redirect to internal paths within your site or external URLs.\n\n```yaml\nredirects:\n - source: \"/old-path\"\n destination: \"/new-path\"\n```\n\nBoth source and destination paths support regex. See https://github.com/pillarjs/path-to-regexp"
},
"docs.RedirectsConfiguration": {
"anyOf": [
{
"type": "array",
"items": {
"$ref": "#/definitions/docs.RedirectConfig"
}
},
{
"type": "string"
}
],
"description": "Either an inline list of redirects, or a relative filepath to a YAML file containing\nonly the list of redirects.\n\n```yaml\nredirects: ./redirects.yml\n```"
},
"docs.CheckRuleSeverity": {
"type": "string",
"enum": [
Expand Down
15 changes: 14 additions & 1 deletion fern/apis/docs-yml/definition/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ types:

# seo
metadata: optional<MetadataConfig>
redirects: optional<list<RedirectConfig>>
redirects: optional<RedirectsConfiguration>

# validation
check: optional<CheckConfig>
Expand Down Expand Up @@ -2228,6 +2228,19 @@ types:
- dark
- light

RedirectsConfiguration:
discriminated: false
docs: |
Either an inline list of redirects, or a relative filepath to a YAML file containing
only the list of redirects.

```yaml
redirects: ./redirects.yml
```
union:
- list<RedirectConfig>
- string

RedirectConfig:
availability: in-development
docs: |
Expand Down
15 changes: 11 additions & 4 deletions packages/cli/cli-v2/src/docs/adapter/LegacyDocsWorkspaceAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
import type { docsYml } from "@fern-api/configuration-loader";
import { type docsYml, resolveRedirects } from "@fern-api/configuration-loader";
import { type AbsoluteFilePath, dirname } from "@fern-api/fs-utils";
import type { DocsWorkspace } from "@fern-api/workspace-loader";
import type { DocsConfig } from "../config/DocsConfig.js";

export class LegacyDocsWorkspaceAdapter {
public adapt({
public async adapt({
docsConfig,
absoluteFilePath
}: {
docsConfig: DocsConfig;
absoluteFilePath: AbsoluteFilePath;
}): DocsWorkspace {
}): Promise<DocsWorkspace> {
// absoluteFilePath is the path to the docs.yml file.
// DocsWorkspace.absoluteFilePath must be the containing directory (the "fern folder").
const docsFilePath = docsConfig.absoluteFilePath ?? absoluteFilePath;
const raw = docsConfig.raw as docsYml.RawSchemas.DocsConfiguration;
return {
type: "docs",
workspaceName: undefined,
absoluteFilePath: dirname(docsFilePath),
absoluteFilepathToDocsConfig: docsFilePath,
config: docsConfig.raw as docsYml.RawSchemas.DocsConfiguration
config: {
...raw,
redirects: await resolveRedirects({
redirects: raw.redirects,
absoluteFilepathToDocsConfig: docsFilePath
})
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class LegacyProjectAdapter {
const apiWorkspaces = await this.buildApiWorkspaces(workspace);
const docsWorkspace =
workspace.docs != null
? this.docsAdapter.adapt({
? await this.docsAdapter.adapt({
docsConfig: workspace.docs,
absoluteFilePath:
workspace.docs.absoluteFilePath ?? workspace.absoluteFilePath ?? this.context.cwd
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/cli/changes/unreleased/redirects-filepath.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- summary: |
`redirects` in `docs.yml` now accepts a relative filepath to a YAML file containing only the
list of redirects, in addition to an inline list. The file's contents are validated the same
way as inline redirects.
type: feat
3 changes: 3 additions & 0 deletions packages/cli/config/src/schemas/docs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ export type MetadataConfigSchema = z.infer<typeof S.MetadataConfig>;
export const RedirectConfigSchema = S.RedirectConfig;
export type RedirectConfigSchema = z.infer<typeof S.RedirectConfig>;

export const RedirectsConfigurationSchema = S.RedirectsConfiguration;
export type RedirectsConfigurationSchema = z.infer<typeof S.RedirectsConfiguration>;

// AI
export const AiChatConfigSchema = S.AIChatConfig;
export type AiChatConfigSchema = z.infer<typeof S.AIChatConfig>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { AbsoluteFilePath, join, RelativeFilePath } from "@fern-api/fs-utils";

import { mkdtemp, writeFile } from "fs/promises";
import { tmpdir } from "os";
import path from "path";
import { beforeAll, describe, expect, it } from "vitest";

import { resolveRedirects } from "../resolveRedirects.js";

describe("resolveRedirects", () => {
let absoluteFilepathToDocsConfig: AbsoluteFilePath;

beforeAll(async () => {
const dir = AbsoluteFilePath.of(await mkdtemp(path.join(tmpdir(), "resolve-redirects-")));
absoluteFilepathToDocsConfig = join(dir, RelativeFilePath.of("docs.yml"));
await writeFile(
join(dir, RelativeFilePath.of("redirects.yml")),
[
"- source: /old-plants",
" destination: /plants",
"- source: /plants/:plantId/legacy",
" destination: /plants/:plantId",
" permanent: false"
].join("\n")
);
await writeFile(join(dir, RelativeFilePath.of("invalid.yml")), "- source: /old-plants\n target: /plants");
await writeFile(join(dir, RelativeFilePath.of("not-a-list.yml")), "redirects:\n - source: /old-plants");
});

it("passes through an inline list", async () => {
const redirects = [{ source: "/old-plants", destination: "/plants" }];
expect(await resolveRedirects({ redirects, absoluteFilepathToDocsConfig })).toEqual(redirects);
});

it("passes through undefined", async () => {
expect(await resolveRedirects({ redirects: undefined, absoluteFilepathToDocsConfig })).toBeUndefined();
});

it("loads redirects from a filepath", async () => {
expect(await resolveRedirects({ redirects: "./redirects.yml", absoluteFilepathToDocsConfig })).toEqual([
{ source: "/old-plants", destination: "/plants" },
{ source: "/plants/:plantId/legacy", destination: "/plants/:plantId", permanent: false }
]);
});

it("fails when the file does not exist", async () => {
await expect(
resolveRedirects({ redirects: "./missing.yml", absoluteFilepathToDocsConfig })
).rejects.toThrowError(/does not exist/);
});

it("fails when a redirect is invalid", async () => {
await expect(
resolveRedirects({ redirects: "./invalid.yml", absoluteFilepathToDocsConfig })
).rejects.toThrowError(/Failed to parse/);
});

it("fails when the file holds anything other than a list of redirects", async () => {
await expect(
resolveRedirects({ redirects: "./not-a-list.yml", absoluteFilepathToDocsConfig })
).rejects.toThrowError(/Failed to parse/);
});
});
1 change: 1 addition & 0 deletions packages/cli/configuration-loader/src/docs-yml/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { getColorFromRawConfig, getColorType } from "./convertColorsConfiguratio
export { getAllPages } from "./getAllPages.js";
export { getReferencedApiSections } from "./getReferencedApiSections.js";
export { parseAudiences, parseDocsConfiguration, resolveFilepath } from "./parseDocsConfiguration.js";
export { type DocsConfigurationWithResolvedRedirects, resolveRedirects } from "./resolveRedirects.js";
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { WithoutQuestionMarks } from "../commons/WithoutQuestionMarks.js";
import { convertColorsConfiguration } from "./convertColorsConfiguration.js";
import { getAllPages, loadAllPages } from "./getAllPages.js";
import { buildNavigationForDirectory, getFrontmatterMetadata, nameToSlug, nameToTitle } from "./navigationUtils.js";
import { resolveRedirects } from "./resolveRedirects.js";

function shouldProcessIconPath(iconPath?: string): boolean {
if (!iconPath || iconPath.startsWith("<")) {
Expand Down Expand Up @@ -126,6 +127,8 @@ export async function parseDocsConfiguration({
})
: undefined;

const redirectsPromise = resolveRedirects({ redirects, absoluteFilepathToDocsConfig });

const cssPromise = convertCssConfig(rawCssConfig, absoluteFilepathToDocsConfig);
const jsPromise = convertJsConfig(rawJsConfig, absoluteFilepathToDocsConfig);

Expand Down Expand Up @@ -183,6 +186,7 @@ export async function parseDocsConfiguration({
css,
js,
metadata,
resolvedRedirects,
context7File,
llmsTxtFile,
llmsFullTxtFile,
Expand All @@ -196,6 +200,7 @@ export async function parseDocsConfiguration({
cssPromise,
jsPromise,
metadataPromise,
redirectsPromise,
context7FilePromise,
llmsTxtFilePromise,
llmsFullTxtFilePromise,
Expand Down Expand Up @@ -246,10 +251,7 @@ export async function parseDocsConfiguration({

/* seo */
metadata,
redirects: redirects?.map((redirect) => ({
...redirect,
permanent: redirect?.permanent
})),
redirects: resolvedRedirects,

/* branding */
logo,
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/configuration-loader/src/docs-yml/resolveRedirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { docsYml } from "@fern-api/configuration";
import { AbsoluteFilePath, dirname, doesPathExist, RelativeFilePath, resolve } from "@fern-api/fs-utils";
import { CliError } from "@fern-api/task-context";

import { readFile } from "fs/promises";
import yaml from "js-yaml";

const RedirectsFile = docsYml.DocsYmlSchemas.RedirectConfig.strict().array();

/**
* A docs.yml configuration whose `redirects` filepath (if any) has already been read off disk.
*/
export type DocsConfigurationWithResolvedRedirects = Omit<docsYml.RawSchemas.DocsConfiguration, "redirects"> & {
redirects?: docsYml.RawSchemas.RedirectConfig[];
};

/**
* `redirects` accepts either an inline list or a relative filepath to a YAML file containing only
* that list. Resolving the file here lets every downstream consumer work with a plain list.
*/
export async function resolveRedirects({
redirects,
absoluteFilepathToDocsConfig
}: {
redirects: docsYml.RawSchemas.RedirectsConfiguration | undefined;
absoluteFilepathToDocsConfig: AbsoluteFilePath;
}): Promise<docsYml.RawSchemas.RedirectConfig[] | undefined> {
if (redirects == null || typeof redirects !== "string") {
return redirects;
}

const absoluteFilepathToRedirects = resolve(dirname(absoluteFilepathToDocsConfig), RelativeFilePath.of(redirects));
if (!(await doesPathExist(absoluteFilepathToRedirects))) {
throw new CliError({
message: `Failed to load redirects: ${absoluteFilepathToRedirects} does not exist`,
code: CliError.Code.ParseError
});
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pointing redirects at an absolute file path crashes the CLI with an internal error

The redirects path from docs.yml is required to be relative (RelativeFilePath.of(redirects) at packages/cli/configuration-loader/src/docs-yml/resolveRedirects.ts:32) before any friendly error handling runs, so users who give a full path see an internal crash instead of a clear message.
Impact: The command aborts with a raw internal error and no guidance about what is wrong in the configuration file.

Mechanism: RelativeFilePath.of throws a plain Error for absolute inputs, bypassing CliError handling

RelativeFilePath.of throws new Error("Filepath is not relative: " + value) when given an absolute path (packages/commons/fs-utils/src/RelativeFilePath.ts:11-13). resolveRedirects is invoked during workspace load (packages/cli/workspace/loader/src/loadDocsWorkspace.ts:124-127) and in parseDocsConfiguration, neither of which converts that generic error into a CliError, so every command that loads docs (check, generate, docs dev) fails with an unexpected-error stack rather than the intended Failed to load redirects: ... message.

The rest of the docs config resolves user-supplied paths with resolve(dirname(absoluteFilepathToDocsConfig), unresolvedFilepath) (packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts:1694-1702), which tolerates absolute inputs; resolveRedirects deviates from that convention.

A related edge case: redirects: "" resolves to the containing directory, doesPathExist returns true, and readFile then throws an uncaught EISDIR.

Suggested change
const absoluteFilepathToRedirects = resolve(dirname(absoluteFilepathToDocsConfig), RelativeFilePath.of(redirects));
if (!(await doesPathExist(absoluteFilepathToRedirects))) {
throw new CliError({
message: `Failed to load redirects: ${absoluteFilepathToRedirects} does not exist`,
code: CliError.Code.ParseError
});
}
const absoluteFilepathToRedirects = resolve(dirname(absoluteFilepathToDocsConfig), redirects);
if (!(await doesPathExist(absoluteFilepathToRedirects))) {
throw new CliError({
message: `Failed to load redirects: ${absoluteFilepathToRedirects} does not exist`,
code: CliError.Code.ParseError
});
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in bf96a5b. resolveRedirects now uses resolve(dirname(docsConfig), redirects) directly (matching resolveFilepath), so absolute paths work instead of throwing a raw Filepath is not relative error. Also guarded the redirects: "" case and switched to doesPathExist(..., "file") so a directory reports is not a file rather than blowing up with EISDIR. Tests added for both.


let contents: unknown;
try {
contents = yaml.load((await readFile(absoluteFilepathToRedirects)).toString());
} catch (error) {
if (!(error instanceof yaml.YAMLException)) {
throw error;
}
throw new CliError({
message: `Failed to parse ${absoluteFilepathToRedirects}: ${error.message}`,
code: CliError.Code.ParseError
});
}

const parsed = RedirectsFile.safeParse(contents ?? []);
if (!parsed.success) {
throw new CliError({
message: `Failed to parse ${absoluteFilepathToRedirects}. The file must contain only a list of redirects:\n${parsed.error.issues
.map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`)
.join("\n")}`,
code: CliError.Code.ParseError
});
}

return parsed.data;
}
7 changes: 6 additions & 1 deletion packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,11 @@ export const RedirectConfig = z.object({
permanent: z.boolean().optional()
});

/**
* Either an inline list of redirects, or a relative filepath to a YAML file containing only that list.
*/
export const RedirectsConfiguration = z.union([z.array(RedirectConfig), z.string()]);

// ===== Check =====

export const CheckRuleSeverity = z.enum(["warn", "error"]);
Expand Down Expand Up @@ -1017,7 +1022,7 @@ export const DocsConfiguration = z.object({
"ai-examples": AiExamplesConfig.optional(),
agents: AgentsConfig.optional(),
metadata: MetadataConfig.optional(),
redirects: z.array(RedirectConfig).optional(),
redirects: RedirectsConfiguration.optional(),
check: CheckConfig.optional(),
logo: LogoConfiguration.optional(),
favicon: z.string().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export interface DocsConfiguration {
/** Configuration for agent-serving endpoints. */
agents?: FernDocsConfig.AgentsConfig;
metadata?: FernDocsConfig.MetadataConfig;
redirects?: FernDocsConfig.RedirectConfig[];
redirects?: FernDocsConfig.RedirectsConfiguration;
check?: FernDocsConfig.CheckConfig;
logo?: FernDocsConfig.LogoConfiguration;
/** Relative filepath to the favicon. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// This file was auto-generated by Fern from our API Definition.

import type * as FernDocsConfig from "../../../index.js";

/**
* Either an inline list of redirects, or a relative filepath to a YAML file containing
* only the list of redirects.
*
* ```yaml
* redirects: ./redirects.yml
* ```
*/
export type RedirectsConfiguration = FernDocsConfig.RedirectConfig[] | string;
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export * from "./ProductPath.js";
export * from "./ProductSwitcherThemeConfig.js";
export * from "./ProgrammingLanguage.js";
export * from "./RedirectConfig.js";
export * from "./RedirectsConfiguration.js";
export * from "./RelativeProductPath.js";
export * from "./Role.js";
export * from "./RoleId.js";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { PageActionsConfig } from "./PageActionsConfig.js";
import { PageConfiguration } from "./PageConfiguration.js";
import { ProductConfig } from "./ProductConfig.js";
import { ProgrammingLanguage } from "./ProgrammingLanguage.js";
import { RedirectConfig } from "./RedirectConfig.js";
import { RedirectsConfiguration } from "./RedirectsConfiguration.js";
import { RoleId } from "./RoleId.js";
import { TabConfig } from "./TabConfig.js";
import { TabId } from "./TabId.js";
Expand Down Expand Up @@ -66,7 +66,7 @@ export const DocsConfiguration: core.serialization.ObjectSchema<
aiExamples: core.serialization.property("ai-examples", AiExamplesConfig.optional()),
agents: AgentsConfig.optional(),
metadata: MetadataConfig.optional(),
redirects: core.serialization.list(RedirectConfig).optional(),
redirects: RedirectsConfiguration.optional(),
check: CheckConfig.optional(),
logo: LogoConfiguration.optional(),
favicon: core.serialization.string().optional(),
Expand Down Expand Up @@ -109,7 +109,7 @@ export declare namespace DocsConfiguration {
"ai-examples"?: AiExamplesConfig.Raw | null;
agents?: AgentsConfig.Raw | null;
metadata?: MetadataConfig.Raw | null;
redirects?: RedirectConfig.Raw[] | null;
redirects?: RedirectsConfiguration.Raw | null;
check?: CheckConfig.Raw | null;
logo?: LogoConfiguration.Raw | null;
favicon?: string | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// This file was auto-generated by Fern from our API Definition.

import type * as FernDocsConfig from "../../../../api/index.js";
import * as core from "../../../../core/index.js";
import type * as serializers from "../../../index.js";
import { RedirectConfig } from "./RedirectConfig.js";

export const RedirectsConfiguration: core.serialization.Schema<
serializers.RedirectsConfiguration.Raw,
FernDocsConfig.RedirectsConfiguration
> = core.serialization.undiscriminatedUnion([core.serialization.list(RedirectConfig), core.serialization.string()]);

export declare namespace RedirectsConfiguration {
export type Raw = RedirectConfig.Raw[] | string;
}
Loading
Loading