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
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
43 changes: 25 additions & 18 deletions fern-yml.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2040,26 +2040,33 @@
"additionalProperties": false
},
"redirects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "string"
},
"destination": {
"type": "string"
},
"permanent": {
"type": "boolean"
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "string"
},
"destination": {
"type": "string"
},
"permanent": {
"type": "boolean"
}
},
"required": [
"source",
"destination"
],
"additionalProperties": false
}
},
"required": [
"source",
"destination"
],
"additionalProperties": false
}
{
"type": "string"
}
]
},
"check": {
"type": "object",
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,81 @@
import { AbsoluteFilePath, dirname, 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")),
[
"redirects:",
" - source: /old-plants",
" destination: /plants",
" - source: /plants/:plantId/legacy",
" destination: /plants/:plantId",
" permanent: false"
].join("\n")
);
await writeFile(
join(dir, RelativeFilePath.of("invalid.yml")),
"redirects:\n - source: /old-plants\n target: /plants"
);
await writeFile(
join(dir, RelativeFilePath.of("bare-list.yml")),
"- source: /old-plants\n destination: /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("loads redirects from an absolute filepath", async () => {
const absolute = join(dirname(absoluteFilepathToDocsConfig), RelativeFilePath.of("redirects.yml"));
expect(await resolveRedirects({ redirects: absolute, absoluteFilepathToDocsConfig })).toHaveLength(2);
});

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

it("fails when the filepath is empty", async () => {
await expect(resolveRedirects({ redirects: "", absoluteFilepathToDocsConfig })).rejects.toThrowError(
/is not a file/
);
});

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 is missing the `redirects` key", async () => {
await expect(
resolveRedirects({ redirects: "./bare-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, 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.RedirectsFile;

/**
* 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), redirects);
if (redirects.trim().length === 0 || !(await doesPathExist(absoluteFilepathToRedirects, "file"))) {
throw new CliError({
message: `Failed to load redirects: ${absoluteFilepathToRedirects} is not a file`,
code: CliError.Code.ParseError
});
}

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

return parsed.data.redirects;
}
Loading
Loading