diff --git a/docs-yml.schema.json b/docs-yml.schema.json index 9603e40fc88c..18187b7fe2cb 100644 --- a/docs-yml.schema.json +++ b/docs-yml.schema.json @@ -275,10 +275,7 @@ "redirects": { "oneOf": [ { - "type": "array", - "items": { - "$ref": "#/definitions/docs.RedirectConfig" - } + "$ref": "#/definitions/docs.RedirectsConfiguration" }, { "type": "null" @@ -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": [ diff --git a/fern-yml.schema.json b/fern-yml.schema.json index 7208a095420f..0529c59eee1b 100644 --- a/fern-yml.schema.json +++ b/fern-yml.schema.json @@ -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", diff --git a/fern/apis/docs-yml/definition/docs.yml b/fern/apis/docs-yml/definition/docs.yml index ec7dce785442..2e707c2d7b53 100644 --- a/fern/apis/docs-yml/definition/docs.yml +++ b/fern/apis/docs-yml/definition/docs.yml @@ -339,7 +339,7 @@ types: # seo metadata: optional - redirects: optional> + redirects: optional # validation check: optional @@ -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 + - string + RedirectConfig: availability: in-development docs: | diff --git a/packages/cli/cli-v2/src/docs/adapter/LegacyDocsWorkspaceAdapter.ts b/packages/cli/cli-v2/src/docs/adapter/LegacyDocsWorkspaceAdapter.ts index 64205829af82..d6134bc42421 100644 --- a/packages/cli/cli-v2/src/docs/adapter/LegacyDocsWorkspaceAdapter.ts +++ b/packages/cli/cli-v2/src/docs/adapter/LegacyDocsWorkspaceAdapter.ts @@ -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 { // 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 + }) + } }; } } diff --git a/packages/cli/cli-v2/src/docs/adapter/LegacyProjectAdapter.ts b/packages/cli/cli-v2/src/docs/adapter/LegacyProjectAdapter.ts index a58f5c0c8821..4dd39165dc36 100644 --- a/packages/cli/cli-v2/src/docs/adapter/LegacyProjectAdapter.ts +++ b/packages/cli/cli-v2/src/docs/adapter/LegacyProjectAdapter.ts @@ -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 diff --git a/packages/cli/cli/changes/unreleased/redirects-filepath.yml b/packages/cli/cli/changes/unreleased/redirects-filepath.yml new file mode 100644 index 000000000000..0bb86787f29d --- /dev/null +++ b/packages/cli/cli/changes/unreleased/redirects-filepath.yml @@ -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 diff --git a/packages/cli/config/src/schemas/docs/index.ts b/packages/cli/config/src/schemas/docs/index.ts index a45cd54d03d0..5c49d93eb5f5 100644 --- a/packages/cli/config/src/schemas/docs/index.ts +++ b/packages/cli/config/src/schemas/docs/index.ts @@ -111,6 +111,9 @@ export type MetadataConfigSchema = z.infer; export const RedirectConfigSchema = S.RedirectConfig; export type RedirectConfigSchema = z.infer; +export const RedirectsConfigurationSchema = S.RedirectsConfiguration; +export type RedirectsConfigurationSchema = z.infer; + // AI export const AiChatConfigSchema = S.AIChatConfig; export type AiChatConfigSchema = z.infer; diff --git a/packages/cli/configuration-loader/src/docs-yml/__test__/resolveRedirects.test.ts b/packages/cli/configuration-loader/src/docs-yml/__test__/resolveRedirects.test.ts new file mode 100644 index 000000000000..6595f7c6d57a --- /dev/null +++ b/packages/cli/configuration-loader/src/docs-yml/__test__/resolveRedirects.test.ts @@ -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/); + }); +}); diff --git a/packages/cli/configuration-loader/src/docs-yml/index.ts b/packages/cli/configuration-loader/src/docs-yml/index.ts index dedc52d2ec37..a2a6aeb9bf67 100644 --- a/packages/cli/configuration-loader/src/docs-yml/index.ts +++ b/packages/cli/configuration-loader/src/docs-yml/index.ts @@ -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"; diff --git a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts index be6f91339131..dec1ac0e846d 100644 --- a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts +++ b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts @@ -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("<")) { @@ -126,6 +127,8 @@ export async function parseDocsConfiguration({ }) : undefined; + const redirectsPromise = resolveRedirects({ redirects, absoluteFilepathToDocsConfig }); + const cssPromise = convertCssConfig(rawCssConfig, absoluteFilepathToDocsConfig); const jsPromise = convertJsConfig(rawJsConfig, absoluteFilepathToDocsConfig); @@ -183,6 +186,7 @@ export async function parseDocsConfiguration({ css, js, metadata, + resolvedRedirects, context7File, llmsTxtFile, llmsFullTxtFile, @@ -196,6 +200,7 @@ export async function parseDocsConfiguration({ cssPromise, jsPromise, metadataPromise, + redirectsPromise, context7FilePromise, llmsTxtFilePromise, llmsFullTxtFilePromise, @@ -246,10 +251,7 @@ export async function parseDocsConfiguration({ /* seo */ metadata, - redirects: redirects?.map((redirect) => ({ - ...redirect, - permanent: redirect?.permanent - })), + redirects: resolvedRedirects, /* branding */ logo, diff --git a/packages/cli/configuration-loader/src/docs-yml/resolveRedirects.ts b/packages/cli/configuration-loader/src/docs-yml/resolveRedirects.ts new file mode 100644 index 000000000000..5dc206ad3ab1 --- /dev/null +++ b/packages/cli/configuration-loader/src/docs-yml/resolveRedirects.ts @@ -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 & { + 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 { + 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; +} diff --git a/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts b/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts index 9cb9dce6b644..26bebd472c27 100644 --- a/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts +++ b/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts @@ -590,6 +590,20 @@ 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()]); + +/** + * The contents of a standalone redirects file referenced by `redirects` in docs.yml. + */ +export const RedirectsFile = z + .object({ + redirects: z.array(RedirectConfig.strict()) + }) + .strict(); + // ===== Check ===== export const CheckRuleSeverity = z.enum(["warn", "error"]); @@ -1017,7 +1031,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(), diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/DocsConfiguration.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/DocsConfiguration.ts index f65854c86fd9..a3cb6e616036 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/DocsConfiguration.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/DocsConfiguration.ts @@ -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. */ diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/RedirectsConfiguration.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/RedirectsConfiguration.ts new file mode 100644 index 000000000000..c2a211e630ec --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/RedirectsConfiguration.ts @@ -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; diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/index.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/index.ts index 84d60bfdde9a..467604966d9b 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/index.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/index.ts @@ -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"; diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/DocsConfiguration.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/DocsConfiguration.ts index 6424eb755c29..d0dd30c5b7a8 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/DocsConfiguration.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/DocsConfiguration.ts @@ -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"; @@ -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(), @@ -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; diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/RedirectsConfiguration.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/RedirectsConfiguration.ts new file mode 100644 index 000000000000..1eaf1e70d591 --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/RedirectsConfiguration.ts @@ -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; +} diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/index.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/index.ts index 84d60bfdde9a..467604966d9b 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/index.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/index.ts @@ -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"; diff --git a/packages/cli/docs-resolver/src/stitchGlobalTheme.ts b/packages/cli/docs-resolver/src/stitchGlobalTheme.ts index e5994c4a56f1..db33b940b31d 100644 --- a/packages/cli/docs-resolver/src/stitchGlobalTheme.ts +++ b/packages/cli/docs-resolver/src/stitchGlobalTheme.ts @@ -1,4 +1,5 @@ import { docsYml } from "@fern-api/configuration"; +import { DocsConfigurationWithResolvedRedirects } from "@fern-api/configuration-loader"; import { isPlainObject } from "@fern-api/core-utils"; import { AbsoluteFilePath } from "@fern-api/fs-utils"; import { CliError, TaskContext } from "@fern-api/task-context"; @@ -9,7 +10,7 @@ import mime from "mime-types"; import path from "path"; import tmp from "tmp-promise"; -type RawDocsConfig = docsYml.RawSchemas.DocsConfiguration; +type RawDocsConfig = DocsConfigurationWithResolvedRedirects; // Theme-eligible fields that can contain local file paths (strings that become // { hash } sentinels on upload and presigned S3 URLs on GET from FDR). diff --git a/packages/cli/workspace/loader/src/docs-yml.schema.json b/packages/cli/workspace/loader/src/docs-yml.schema.json index 9603e40fc88c..18187b7fe2cb 100644 --- a/packages/cli/workspace/loader/src/docs-yml.schema.json +++ b/packages/cli/workspace/loader/src/docs-yml.schema.json @@ -275,10 +275,7 @@ "redirects": { "oneOf": [ { - "type": "array", - "items": { - "$ref": "#/definitions/docs.RedirectConfig" - } + "$ref": "#/definitions/docs.RedirectsConfiguration" }, { "type": "null" @@ -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": [ diff --git a/packages/cli/workspace/loader/src/loadDocsWorkspace.ts b/packages/cli/workspace/loader/src/loadDocsWorkspace.ts index 92a9e61894a7..8e59b76ac561 100644 --- a/packages/cli/workspace/loader/src/loadDocsWorkspace.ts +++ b/packages/cli/workspace/loader/src/loadDocsWorkspace.ts @@ -1,4 +1,9 @@ -import { DOCS_CONFIGURATION_FILENAME, docsYml } from "@fern-api/configuration-loader"; +import { + DOCS_CONFIGURATION_FILENAME, + DocsConfigurationWithResolvedRedirects, + docsYml, + resolveRedirects +} from "@fern-api/configuration-loader"; import { extractErrorMessage, sanitizeNullValues, validateAgainstJsonSchema } from "@fern-api/core-utils"; import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; import { CliError, TaskContext } from "@fern-api/task-context"; @@ -43,7 +48,7 @@ export async function loadDocsConfiguration({ }: { absolutePathToDocsDefinition: AbsoluteFilePath; context: TaskContext; -}): Promise { +}): Promise { if (!(await doesPathExist(absolutePathToDocsDefinition))) { return undefined; } @@ -63,7 +68,7 @@ export async function loadRawDocsConfiguration({ }: { absolutePathOfConfiguration: AbsoluteFilePath; context: TaskContext; -}): Promise { +}): Promise { const contentsStr = await readFile(absolutePathOfConfiguration); let contentsJson: unknown; try { @@ -95,9 +100,10 @@ export async function loadRawDocsConfiguration({ context.logger.debug(`No null/undefined values found during sanitization`); } + let parsed: docsYml.RawSchemas.DocsConfiguration; try { context.logger.debug(`Attempting to parse sanitized docs configuration`); - return docsYml.RawSchemas.Serializer.DocsConfiguration.parseOrThrow(sanitizedJson); + parsed = docsYml.RawSchemas.Serializer.DocsConfiguration.parseOrThrow(sanitizedJson); } catch (err) { context.logger.error(`Parsing failed even after sanitization: ${extractErrorMessage(err)}`); // Log the JSON structure to debug @@ -107,6 +113,14 @@ export async function loadRawDocsConfiguration({ code: CliError.Code.ParseError }); } + + return { + ...parsed, + redirects: await resolveRedirects({ + redirects: parsed.redirects, + absoluteFilepathToDocsConfig: absolutePathOfConfiguration + }) + }; } else { throw new CliError({ message: `Failed to parse docs.yml:\n${result.error?.message ?? "Unknown error"}`, diff --git a/packages/cli/workspace/loader/src/types/Workspace.ts b/packages/cli/workspace/loader/src/types/Workspace.ts index dd7e958e45cd..4d8c9f5aef55 100644 --- a/packages/cli/workspace/loader/src/types/Workspace.ts +++ b/packages/cli/workspace/loader/src/types/Workspace.ts @@ -1,5 +1,5 @@ import { AbstractAPIWorkspace } from "@fern-api/api-workspace-commons"; -import { docsYml } from "@fern-api/configuration-loader"; +import { DocsConfigurationWithResolvedRedirects } from "@fern-api/configuration-loader"; import { AbsoluteFilePath } from "@fern-api/fs-utils"; export type Workspace = DocsWorkspace | AbstractAPIWorkspace; @@ -9,5 +9,5 @@ export interface DocsWorkspace { workspaceName: string | undefined; absoluteFilePath: AbsoluteFilePath; // path to the fern folder (dirname(absoluteFilepathToDocsConfig)) absoluteFilepathToDocsConfig: AbsoluteFilePath; - config: docsYml.RawSchemas.DocsConfiguration; + config: DocsConfigurationWithResolvedRedirects; } diff --git a/packages/cli/yaml/docs-validator/src/docsAst/DocsConfigFileAstVisitor.ts b/packages/cli/yaml/docs-validator/src/docsAst/DocsConfigFileAstVisitor.ts index 970ee2620c1b..7d7902301f5b 100644 --- a/packages/cli/yaml/docs-validator/src/docsAst/DocsConfigFileAstVisitor.ts +++ b/packages/cli/yaml/docs-validator/src/docsAst/DocsConfigFileAstVisitor.ts @@ -1,4 +1,4 @@ -import { docsYml } from "@fern-api/configuration-loader"; +import { DocsConfigurationWithResolvedRedirects, docsYml } from "@fern-api/configuration-loader"; import { NodePath } from "@fern-api/fern-definition-schema"; import { AbsoluteFilePath } from "@fern-api/fs-utils"; import { TaskContext } from "@fern-api/task-context"; @@ -9,7 +9,7 @@ export type DocsConfigFileAstVisitor> = { }; export interface DocsConfigFileAstNodeTypes { - file: { config: docsYml.RawSchemas.DocsConfiguration }; + file: { config: DocsConfigurationWithResolvedRedirects }; filepath: { absoluteFilepath: AbsoluteFilePath; value: string /* User defined value for filepath */; diff --git a/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileYamlAst.ts b/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileYamlAst.ts index 663689bd557c..c597a20a46fd 100644 --- a/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileYamlAst.ts +++ b/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileYamlAst.ts @@ -1,4 +1,4 @@ -import { docsYml } from "@fern-api/configuration-loader"; +import { DocsConfigurationWithResolvedRedirects, docsYml } from "@fern-api/configuration-loader"; import { noop, visitObjectAsync } from "@fern-api/core-utils"; import { NodePath } from "@fern-api/fern-definition-schema"; import { AbsoluteFilePath, dirname, doesPathExist, resolve } from "@fern-api/fs-utils"; @@ -15,7 +15,7 @@ import { visitNavigationAst } from "./visitNavigationAst.js"; export declare namespace visitDocsConfigFileYamlAst { interface Args { - contents: docsYml.RawSchemas.DocsConfiguration; + contents: DocsConfigurationWithResolvedRedirects; visitor: Partial; absoluteFilepathToConfiguration: AbsoluteFilePath; absolutePathToFernFolder: AbsoluteFilePath; diff --git a/packages/cli/yaml/docs-validator/src/rules/navigation-conflicts/__test__/navigation-conflicts.test.ts b/packages/cli/yaml/docs-validator/src/rules/navigation-conflicts/__test__/navigation-conflicts.test.ts index ec71935824ff..342caba6459a 100644 --- a/packages/cli/yaml/docs-validator/src/rules/navigation-conflicts/__test__/navigation-conflicts.test.ts +++ b/packages/cli/yaml/docs-validator/src/rules/navigation-conflicts/__test__/navigation-conflicts.test.ts @@ -1,12 +1,12 @@ -import type { docsYml } from "@fern-api/configuration-loader"; +import type { DocsConfigurationWithResolvedRedirects } from "@fern-api/configuration-loader"; import { describe, expect, it } from "vitest"; import type { DocsConfigFileAstNodeTypes } from "../../../docsAst/DocsConfigFileAstVisitor.js"; import type { RuleContext } from "../../../Rule.js"; import { NavigationConflicts } from "../navigation-conflicts.js"; -async function runRuleOnConfig(partialConfig: Partial) { - const config: docsYml.RawSchemas.DocsConfiguration = { instances: [], ...partialConfig }; +async function runRuleOnConfig(partialConfig: Partial) { + const config: DocsConfigurationWithResolvedRedirects = { instances: [], ...partialConfig }; const visitor = await NavigationConflicts.create({} as RuleContext); const fileVisitor = visitor.file; if (fileVisitor == null) {