diff --git a/docs-yml.schema.json b/docs-yml.schema.json index 9d86fee3f48f..f8a55b31842b 100644 --- a/docs-yml.schema.json +++ b/docs-yml.schema.json @@ -1112,6 +1112,16 @@ "type": "null" } ] + }, + "blog": { + "oneOf": [ + { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -2954,6 +2964,89 @@ ], "additionalProperties": false }, + "docs.BlogConfiguration": { + "type": "object", + "properties": { + "viewers": { + "oneOf": [ + { + "$ref": "#/definitions/docs.Role" + }, + { + "type": "null" + } + ] + }, + "orphaned": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "When `orphaned` is set to `true`, the roles will not inherit from parents." + }, + "feature-flag": { + "oneOf": [ + { + "$ref": "#/definitions/docs.FeatureFlagConfiguration" + }, + { + "type": "null" + } + ] + }, + "blog": { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "blog" + ], + "additionalProperties": false + }, "docs.TitleSource": { "type": "string", "enum": [ @@ -3130,6 +3223,9 @@ { "$ref": "#/definitions/docs.ChangelogConfiguration" }, + { + "$ref": "#/definitions/docs.BlogConfiguration" + }, { "$ref": "#/definitions/docs.FolderConfiguration" } diff --git a/fern-yml.schema.json b/fern-yml.schema.json index bd06a6f16571..8f73f31d7d91 100644 --- a/fern-yml.schema.json +++ b/fern-yml.schema.json @@ -386,6 +386,9 @@ }, "changelog": { "type": "string" + }, + "blog": { + "type": "string" } }, "required": [ @@ -5782,6 +5785,84 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "viewers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "orphaned": { + "type": "boolean" + }, + "feature-flag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "flag": { + "type": "string" + }, + "fallback-value": {}, + "match": {} + }, + "required": [ + "flag" + ], + "additionalProperties": false + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "flag": { + "type": "string" + }, + "fallback-value": {}, + "match": {} + }, + "required": [ + "flag" + ], + "additionalProperties": false + } + } + ] + }, + "blog": { + "type": "string" + }, + "title": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": [ + "blog" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/fern/apis/docs-yml/definition/docs.yml b/fern/apis/docs-yml/definition/docs.yml index 3601e7bd892a..cbfd8ce21347 100644 --- a/fern/apis/docs-yml/definition/docs.yml +++ b/fern/apis/docs-yml/definition/docs.yml @@ -626,6 +626,7 @@ types: Tabs with `href` must not have children in the navigation config. target: optional changelog: optional + blog: optional ChangelogFolderRelativePath: type: string @@ -816,6 +817,7 @@ types: - LibraryReferenceConfiguration - LinkConfiguration - ChangelogConfiguration + - BlogConfiguration - FolderConfiguration LogoConfiguration: @@ -1411,6 +1413,16 @@ types: hidden: optional # skip-slug: optional # skip-slug is not needed for changelog + BlogConfiguration: + extends: [WithPermissions, WithFeatureFlags] + properties: + blog: ChangelogFolderRelativePath + title: optional # defaults to "Blog" + slug: optional + icon: optional + hidden: optional + # skip-slug: optional # skip-slug is not needed for blog + SectionConfiguration: extends: [WithPermissions, WithFeatureFlags] properties: diff --git a/packages/cli/cli/changes/unreleased/add-blog-navigation-item.yml b/packages/cli/cli/changes/unreleased/add-blog-navigation-item.yml new file mode 100644 index 000000000000..30f02bf392d1 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/add-blog-navigation-item.yml @@ -0,0 +1,5 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Add a blog navigation item as an alias for changelog navigation. + type: feat diff --git a/packages/cli/configuration-loader/src/docs-yml/__test__/blogNavigationAlias.test.ts b/packages/cli/configuration-loader/src/docs-yml/__test__/blogNavigationAlias.test.ts new file mode 100644 index 000000000000..b58eb37b1cee --- /dev/null +++ b/packages/cli/configuration-loader/src/docs-yml/__test__/blogNavigationAlias.test.ts @@ -0,0 +1,115 @@ +import { docsYml } from "@fern-api/configuration"; +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { createMockTaskContext } from "@fern-api/task-context"; +import { describe, expect, it } from "vitest"; + +import { parseDocsConfiguration } from "../parseDocsConfiguration.js"; + +const FAKE_FERN_DIR = "/fern" as AbsoluteFilePath; +const FAKE_CONFIG_PATH = "/fern/docs.yml" as AbsoluteFilePath; + +async function parseRawDocsYml(rawDocsYml: unknown): Promise { + const rawDocsConfiguration = docsYml.RawSchemas.Serializer.DocsConfiguration.parseOrThrow(rawDocsYml); + return await parseDocsConfiguration({ + rawDocsConfiguration, + absolutePathToFernFolder: FAKE_FERN_DIR, + absoluteFilepathToDocsConfig: FAKE_CONFIG_PATH, + context: createMockTaskContext() + }); +} + +describe("blog navigation alias", () => { + it("normalizes a top-level blog item to a changelog item", async () => { + const changelog = await parseRawDocsYml({ + instances: [], + navigation: [{ changelog: "blog" }] + }); + const blog = await parseRawDocsYml({ + instances: [], + navigation: [{ blog: "blog" }] + }); + if (changelog.navigation.type !== "untabbed" || blog.navigation.type !== "untabbed") { + throw new Error("Expected untabbed navigation"); + } + + expect(changelog.navigation).toEqual({ + type: "untabbed", + items: [ + { + type: "changelog", + changelog: [], + hidden: false, + icon: undefined, + title: "Changelog", + slug: undefined, + viewers: undefined, + orphaned: undefined, + featureFlags: undefined + } + ] + }); + expect(blog.navigation).toEqual({ + ...changelog.navigation, + items: [{ ...changelog.navigation.items[0], title: "Blog" }] + }); + }); + + it("normalizes a tab blog item to a changelog child", async () => { + const changelog = await parseRawDocsYml({ + instances: [], + tabs: { + posts: { + "display-name": "Posts", + changelog: "blog" + } + }, + navigation: [{ tab: "posts" }] + }); + const blog = await parseRawDocsYml({ + instances: [], + tabs: { + posts: { + "display-name": "Posts", + blog: "blog" + } + }, + navigation: [{ tab: "posts" }] + }); + + expect(changelog.navigation).toEqual({ + type: "tabbed", + items: [ + { + title: "Posts", + icon: undefined, + slug: undefined, + skipUrlSlug: undefined, + hidden: undefined, + child: { + type: "changelog", + changelog: [] + }, + viewers: undefined, + orphaned: undefined, + featureFlags: undefined + } + ] + }); + expect(blog.navigation).toEqual(changelog.navigation); + }); + + it("preserves an explicit blog title", async () => { + const parsed = await parseRawDocsYml({ + instances: [], + navigation: [{ blog: "blog", title: "Engineering Blog" }] + }); + + if (parsed.navigation.type !== "untabbed") { + throw new Error("Expected untabbed navigation"); + } + expect(parsed.navigation.items[0]).toMatchObject({ + type: "changelog", + title: "Engineering Blog" + }); + }); +}); diff --git a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts index af145c4c4d01..ff6a0a39db8a 100644 --- a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts +++ b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts @@ -1263,7 +1263,8 @@ async function convertNavigationTabConfiguration({ }; } - if (tab.changelog != null) { + const changelogPath = docsYml.getChangelogFolderFromTabConfig(tab); + if (changelogPath != null) { return { title: tab.displayName, icon: resolveIconPath(tab.icon, absolutePathToConfig), @@ -1272,7 +1273,7 @@ async function convertNavigationTabConfiguration({ hidden: tab.hidden, child: { type: "changelog", - changelog: await listFiles(resolveFilepath(tab.changelog, absolutePathToConfig), "{md,mdx}") + changelog: await listFiles(resolveFilepath(changelogPath, absolutePathToConfig), "{md,mdx}") }, viewers: parseRoles(tab.viewers), orphaned: tab.orphaned, @@ -1409,7 +1410,7 @@ async function expandFolderConfiguration({ } async function convertNavigationItem({ - rawConfig, + rawConfig: rawConfigInput, absolutePathToFernFolder, absolutePathToConfig, context, @@ -1421,6 +1422,8 @@ async function convertNavigationItem({ context: TaskContext; folderTitleSource?: docsYml.RawSchemas.TitleSource; }): Promise { + const rawConfig = normalizeNavigationItem(rawConfigInput); + if (isRawPageConfig(rawConfig)) { return parsePageConfig(rawConfig, absolutePathToConfig); } @@ -1741,6 +1744,25 @@ function isRawChangelogConfig(item: unknown): item is docsYml.RawSchemas.Changel return isPlainObject(item) && typeof item.changelog === "string"; } +function isRawBlogConfig(item: unknown): item is docsYml.RawSchemas.BlogConfiguration { + return isPlainObject(item) && typeof item.blog === "string"; +} + +function normalizeNavigationItem( + rawConfig: docsYml.RawSchemas.NavigationItem +): Exclude { + if (!isRawBlogConfig(rawConfig)) { + return rawConfig; + } + + const { blog, ...rest } = rawConfig; + return { + ...rest, + changelog: blog, + title: rawConfig.title ?? docsYml.DEFAULT_BLOG_TITLE + }; +} + function isRawFolderConfig(item: unknown): item is docsYml.RawSchemas.FolderConfiguration { return isPlainObject(item) && typeof item.folder === "string"; } diff --git a/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts b/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts index 8ba13aa8e0ab..a0e4df2c0334 100644 --- a/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts +++ b/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts @@ -681,6 +681,16 @@ export const ChangelogConfiguration = WithPermissions.merge(WithFeatureFlags).me }) ); +export const BlogConfiguration = WithPermissions.merge(WithFeatureFlags).merge( + z.object({ + blog: ChangelogFolderRelativePath, + title: z.string().optional(), + slug: z.string().optional(), + icon: z.string().optional(), + hidden: z.boolean().optional() + }) +); + // ===== Library Reference Configuration ===== export const LibraryReferenceConfiguration = WithPermissions.merge(WithFeatureFlags).merge( @@ -820,6 +830,7 @@ export const NavigationItem: z.ZodType = z.lazy(() => LibraryReferenceConfiguration, LinkConfiguration, ChangelogConfiguration, + BlogConfiguration, FolderConfiguration ]) ); @@ -884,7 +895,8 @@ export const TabConfig = WithPermissions.merge(WithFeatureFlags).merge( hidden: z.boolean().optional(), href: z.string().optional(), target: Target.optional(), - changelog: ChangelogFolderRelativePath.optional() + changelog: ChangelogFolderRelativePath.optional(), + blog: ChangelogFolderRelativePath.optional() }) ); diff --git a/packages/cli/configuration/src/docs-yml/index.ts b/packages/cli/configuration/src/docs-yml/index.ts index 898410b83caf..4f6315e30810 100644 --- a/packages/cli/configuration/src/docs-yml/index.ts +++ b/packages/cli/configuration/src/docs-yml/index.ts @@ -1,4 +1,5 @@ export * as DocsYmlSchemas from "./DocsYmlSchemas.js"; +export * from "./navigation.js"; export * from "./ParsedDocsConfiguration.js"; export * as RawSchemas from "./schemas/index.js"; export * from "./themeEligibleFields.js"; diff --git a/packages/cli/configuration/src/docs-yml/navigation.ts b/packages/cli/configuration/src/docs-yml/navigation.ts new file mode 100644 index 000000000000..1b2451f5594e --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/navigation.ts @@ -0,0 +1,22 @@ +import * as RawSchemas from "./schemas/index.js"; + +export const DEFAULT_CHANGELOG_TITLE = "Changelog"; +export const DEFAULT_BLOG_TITLE = "Blog"; + +export function getChangelogFolderFromNavigationItem( + item: RawSchemas.NavigationItem +): RawSchemas.ChangelogFolderRelativePath | undefined { + if ("changelog" in item) { + return item.changelog; + } + if ("blog" in item) { + return item.blog; + } + return undefined; +} + +export function getChangelogFolderFromTabConfig( + tab: RawSchemas.TabConfig +): RawSchemas.ChangelogFolderRelativePath | undefined { + return tab.changelog ?? tab.blog; +} diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogConfiguration.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogConfiguration.ts new file mode 100644 index 000000000000..d1d1a75a41a9 --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogConfiguration.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as FernDocsConfig from "../../../index.js"; + +export interface BlogConfiguration extends FernDocsConfig.WithPermissions, FernDocsConfig.WithFeatureFlags { + blog: FernDocsConfig.ChangelogFolderRelativePath; + title?: string; + slug?: string; + icon?: string; + hidden?: boolean; +} diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/NavigationItem.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/NavigationItem.ts index 52bfb6c63ef1..a37b77d20528 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/NavigationItem.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/NavigationItem.ts @@ -9,4 +9,5 @@ export type NavigationItem = | FernDocsConfig.LibraryReferenceConfiguration | FernDocsConfig.LinkConfiguration | FernDocsConfig.ChangelogConfiguration + | FernDocsConfig.BlogConfiguration | FernDocsConfig.FolderConfiguration; diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/TabConfig.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/TabConfig.ts index 2a05b6e67062..be6e303afdc8 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/TabConfig.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/TabConfig.ts @@ -16,4 +16,5 @@ export interface TabConfig extends FernDocsConfig.WithPermissions, FernDocsConfi href?: string; target?: FernDocsConfig.Target; changelog?: FernDocsConfig.ChangelogFolderRelativePath; + blog?: FernDocsConfig.ChangelogFolderRelativePath; } 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..45a6bfb25566 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 @@ -20,6 +20,7 @@ export * from "./AudienceId.js"; export * from "./Availability.js"; export * from "./BackgroundImageConfiguration.js"; export * from "./BackgroundImageThemedConfig.js"; +export * from "./BlogConfiguration.js"; export * from "./BodyThemeConfig.js"; export * from "./ChangelogConfiguration.js"; export * from "./ChangelogFolderRelativePath.js"; diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogConfiguration.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogConfiguration.ts new file mode 100644 index 000000000000..0e74140233ee --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogConfiguration.ts @@ -0,0 +1,32 @@ +// 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 { ChangelogFolderRelativePath } from "./ChangelogFolderRelativePath.js"; +import { WithFeatureFlags } from "./WithFeatureFlags.js"; +import { WithPermissions } from "./WithPermissions.js"; + +export const BlogConfiguration: core.serialization.ObjectSchema< + serializers.BlogConfiguration.Raw, + FernDocsConfig.BlogConfiguration +> = core.serialization + .object({ + blog: ChangelogFolderRelativePath, + title: core.serialization.string().optional(), + slug: core.serialization.string().optional(), + icon: core.serialization.string().optional(), + hidden: core.serialization.boolean().optional(), + }) + .extend(WithPermissions) + .extend(WithFeatureFlags); + +export declare namespace BlogConfiguration { + export interface Raw extends WithPermissions.Raw, WithFeatureFlags.Raw { + blog: ChangelogFolderRelativePath.Raw; + title?: string | null; + slug?: string | null; + icon?: string | null; + hidden?: boolean | null; + } +} diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/NavigationItem.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/NavigationItem.ts index 58e21463e6ca..14cc5ee4cf8d 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/NavigationItem.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/NavigationItem.ts @@ -4,6 +4,7 @@ import type * as FernDocsConfig from "../../../../api/index.js"; import * as core from "../../../../core/index.js"; import * as serializers from "../../../index.js"; import { ApiReferenceConfiguration } from "./ApiReferenceConfiguration.js"; +import { BlogConfiguration } from "./BlogConfiguration.js"; import { ChangelogConfiguration } from "./ChangelogConfiguration.js"; import { FolderConfiguration } from "./FolderConfiguration.js"; import { LibraryReferenceConfiguration } from "./LibraryReferenceConfiguration.js"; @@ -18,6 +19,7 @@ export const NavigationItem: core.serialization.Schema { + it("visits markdown files inside a blog navigation folder", async () => { + const fernFolder = AbsoluteFilePath.of(await mkdtemp(path.join(os.tmpdir(), "fern-blog-validation-"))); + const blogFolder = join(fernFolder, RelativeFilePath.of("blog")); + const docsConfig = join(fernFolder, RelativeFilePath.of("docs.yml")); + const blogPost = join(blogFolder, RelativeFilePath.of("post.mdx")); + await mkdir(blogFolder, { recursive: true }); + await writeFile(docsConfig, "navigation:\n - blog: blog\n"); + await writeFile(blogPost, "[broken link](/missing)"); + + const reportedBrokenLinks: string[] = []; + try { + await visitNavigationAst({ + absolutePathToFernFolder: fernFolder, + navigation: [{ blog: "blog" }], + visitor: { + markdownPage: async ({ content }) => { + if (content.includes("[broken link](/missing)")) { + reportedBrokenLinks.push("/missing"); + } + } + }, + nodePath: [], + absoluteFilepathToConfiguration: docsConfig, + apiWorkspaces: [], + context: createMockTaskContext() + }); + } finally { + await rm(fernFolder, { recursive: true, force: true }); + } + + expect(reportedBrokenLinks).toEqual(["/missing"]); + }); +}); diff --git a/packages/cli/yaml/docs-validator/src/docsAst/products-yml.schema.json b/packages/cli/yaml/docs-validator/src/docsAst/products-yml.schema.json index b203debe0eb2..8f485388a544 100644 --- a/packages/cli/yaml/docs-validator/src/docsAst/products-yml.schema.json +++ b/packages/cli/yaml/docs-validator/src/docsAst/products-yml.schema.json @@ -239,6 +239,16 @@ "type": "null" } ] + }, + "blog": { + "oneOf": [ + { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1723,6 +1733,89 @@ ], "additionalProperties": false }, + "docs.BlogConfiguration": { + "type": "object", + "properties": { + "viewers": { + "oneOf": [ + { + "$ref": "#/definitions/docs.Role" + }, + { + "type": "null" + } + ] + }, + "orphaned": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "When `orphaned` is set to `true`, the roles will not inherit from parents." + }, + "feature-flag": { + "oneOf": [ + { + "$ref": "#/definitions/docs.FeatureFlagConfiguration" + }, + { + "type": "null" + } + ] + }, + "blog": { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "blog" + ], + "additionalProperties": false + }, "docs.TitleSource": { "type": "string", "enum": [ @@ -1899,6 +1992,9 @@ { "$ref": "#/definitions/docs.ChangelogConfiguration" }, + { + "$ref": "#/definitions/docs.BlogConfiguration" + }, { "$ref": "#/definitions/docs.FolderConfiguration" } diff --git a/packages/cli/yaml/docs-validator/src/docsAst/versions-yml.schema.json b/packages/cli/yaml/docs-validator/src/docsAst/versions-yml.schema.json index b203debe0eb2..8f485388a544 100644 --- a/packages/cli/yaml/docs-validator/src/docsAst/versions-yml.schema.json +++ b/packages/cli/yaml/docs-validator/src/docsAst/versions-yml.schema.json @@ -239,6 +239,16 @@ "type": "null" } ] + }, + "blog": { + "oneOf": [ + { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1723,6 +1733,89 @@ ], "additionalProperties": false }, + "docs.BlogConfiguration": { + "type": "object", + "properties": { + "viewers": { + "oneOf": [ + { + "$ref": "#/definitions/docs.Role" + }, + { + "type": "null" + } + ] + }, + "orphaned": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "When `orphaned` is set to `true`, the roles will not inherit from parents." + }, + "feature-flag": { + "oneOf": [ + { + "$ref": "#/definitions/docs.FeatureFlagConfiguration" + }, + { + "type": "null" + } + ] + }, + "blog": { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "blog" + ], + "additionalProperties": false + }, "docs.TitleSource": { "type": "string", "enum": [ @@ -1899,6 +1992,9 @@ { "$ref": "#/definitions/docs.ChangelogConfiguration" }, + { + "$ref": "#/definitions/docs.BlogConfiguration" + }, { "$ref": "#/definitions/docs.FolderConfiguration" } diff --git a/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts b/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts index 27001c4a4413..41b82574fbb8 100644 --- a/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts +++ b/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts @@ -1,4 +1,4 @@ -import { docsYml } from "@fern-api/configuration-loader"; +import { docsYml } from "@fern-api/configuration"; import { noop, visitObjectAsync } from "@fern-api/core-utils"; import { parseImagePaths } from "@fern-api/docs-markdown-utils"; import { NodePath } from "@fern-api/fern-definition-schema"; @@ -277,8 +277,9 @@ async function visitNavigationItem({ } } - if (navigationItemIsChangelog(navigationItem)) { - const changelogDir = resolve(dirname(absoluteFilepathToConfiguration), navigationItem.changelog); + const changelogFolder = docsYml.getChangelogFolderFromNavigationItem(navigationItem); + if (changelogFolder != null) { + const changelogDir = resolve(dirname(absoluteFilepathToConfiguration), changelogFolder); context.logger.trace(`Starting changelog processing for directory: ${changelogDir}`); if (await doesPathExist(changelogDir)) { @@ -399,13 +400,6 @@ async function visitFolderMarkdownFiles({ }); } -function navigationItemIsChangelog( - item: docsYml.RawSchemas.NavigationItem -): item is docsYml.RawSchemas.ChangelogConfiguration { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return (item as docsYml.RawSchemas.ChangelogConfiguration)?.changelog != null; -} - function navigationItemIsPage(item: docsYml.RawSchemas.NavigationItem): item is docsYml.RawSchemas.PageConfiguration { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return (item as docsYml.RawSchemas.PageConfiguration)?.page != null; diff --git a/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/__test__/valid-changelog-slug.test.ts b/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/__test__/valid-changelog-slug.test.ts index 3006d64ca6c8..2ae08da8e78e 100644 --- a/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/__test__/valid-changelog-slug.test.ts +++ b/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/__test__/valid-changelog-slug.test.ts @@ -1,13 +1,26 @@ +import type { docsYml } from "@fern-api/configuration-loader"; import { describe, expect, it } from "vitest"; +import type { RuleContext } from "../../../Rule.js"; import { CHANGELOG_FEED_ALLOWED_SLUGS, getEffectiveChangelogSlugLastSegment, getEffectiveChangelogSlugSegments, hasAllowedChangelogSegment, - isAllowedChangelogSlug + isAllowedChangelogSlug, + ValidChangelogSlugRule } from "../valid-changelog-slug.js"; +async function violationsFor(config: docsYml.RawSchemas.DocsConfiguration): Promise { + const visitor = await ValidChangelogSlugRule.create({} as RuleContext); + const fileVisitor = visitor.file; + if (fileVisitor == null) { + throw new Error("Expected the rule to define a `file` visitor"); + } + const violations = await fileVisitor({ config }); + return violations.map((violation) => violation.message); +} + describe("CHANGELOG_FEED_ALLOWED_SLUGS", () => { it("contains the canonical names", () => { expect(CHANGELOG_FEED_ALLOWED_SLUGS).toEqual([ @@ -166,3 +179,81 @@ describe("integration: ancestors + changelog", () => { expect(hasAllowedChangelogSegment([...ancestors, ...own])).toBe(false); }); }); + +describe("blog navigation aliases", () => { + it("allows a top-level blog navigation item with an allowlisted slug", async () => { + expect( + await violationsFor({ + instances: [], + navigation: [{ blog: "blog", slug: "changelog" }] + }) + ).toEqual([]); + }); + + it("rejects a top-level blog navigation item with a non-allowlisted slug", async () => { + const messages = await violationsFor({ + instances: [], + navigation: [{ blog: "blog", slug: "product-updates" }] + }); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('resolves to URL path "/product-updates"'); + }); + + it("uses the Blog default title for an untitled top-level blog navigation item", async () => { + const messages = await violationsFor({ + instances: [], + navigation: [{ blog: "blog" }] + }); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('resolves to URL path "/blog"'); + expect(messages[0]).toContain('title: "Blog"'); + }); + + it("allows a tab-level blog navigation item with an allowlisted slug", async () => { + expect( + await violationsFor({ + instances: [], + tabs: { + posts: { + displayName: "Posts", + blog: "blog", + slug: "changelog" + } + }, + navigation: [{ tab: "posts" }] + }) + ).toEqual([]); + }); + + it("rejects a tab-level blog navigation item with a non-allowlisted slug", async () => { + const messages = await violationsFor({ + instances: [], + tabs: { + posts: { + displayName: "Posts", + blog: "blog", + slug: "product-updates" + } + }, + navigation: [{ tab: "posts" }] + }); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('resolves to URL path "/product-updates"'); + }); + + it("uses the Blog default title for a tab-level blog navigation item", async () => { + const messages = await violationsFor({ + instances: [], + tabs: { + posts: { + displayName: "Blog", + blog: "blog" + } + }, + navigation: [{ tab: "posts" }] + }); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('resolves to URL path "/blog"'); + expect(messages[0]).toContain('title: "Blog"'); + }); +}); diff --git a/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/valid-changelog-slug.ts b/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/valid-changelog-slug.ts index 687c2527b051..56682928a70b 100644 --- a/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/valid-changelog-slug.ts +++ b/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/valid-changelog-slug.ts @@ -1,4 +1,4 @@ -import { docsYml } from "@fern-api/configuration-loader"; +import { docsYml } from "@fern-api/configuration"; import { kebabCase } from "lodash-es"; import { validateProductConfigFileSchema } from "../../docsAst/validateProductConfig.js"; @@ -25,8 +25,6 @@ export const CHANGELOG_FEED_ALLOWED_SLUGS: readonly string[] = [ "whatsnew" ]; -const DEFAULT_CHANGELOG_TITLE = "Changelog"; - /** * Computes the URL segments contributed by a changelog node itself. Mirrors * the slug computation in `packages/cli/docs-resolver/src/ChangelogNodeConverter.ts`: @@ -34,7 +32,7 @@ const DEFAULT_CHANGELOG_TITLE = "Changelog"; * which may be a nested path like `v2/release-notes`. */ export function getEffectiveChangelogSlugSegments(config: { slug?: string; title?: string }): string[] { - const raw = config.slug ?? kebabCase(config.title ?? DEFAULT_CHANGELOG_TITLE); + const raw = config.slug ?? kebabCase(config.title ?? docsYml.DEFAULT_CHANGELOG_TITLE); return splitSegments(raw); } @@ -101,10 +99,14 @@ function collectChangelogLocations( const out: ChangelogLocation[] = []; for (const item of items) { if (isChangelog(item)) { + const changelogFolder = docsYml.getChangelogFolderFromNavigationItem(item); + if (changelogFolder == null) { + continue; + } out.push({ - where: `${breadcrumb} > changelog (${item.changelog})`, + where: `${breadcrumb} > changelog (${changelogFolder})`, slug: item.slug, - title: item.title, + title: item.title ?? ("blog" in item ? docsYml.DEFAULT_BLOG_TITLE : docsYml.DEFAULT_CHANGELOG_TITLE), ancestorSegments }); continue; @@ -124,6 +126,12 @@ function collectChangelogLocations( return out; } +function isChangelog( + item: docsYml.RawSchemas.NavigationItem +): item is docsYml.RawSchemas.ChangelogConfiguration | docsYml.RawSchemas.BlogConfiguration { + return docsYml.getChangelogFolderFromNavigationItem(item) != null; +} + function collectFromTabs( tabs: Record | undefined, breadcrumb: string, @@ -134,16 +142,21 @@ function collectFromTabs( } const out: ChangelogLocation[] = []; for (const [tabId, tab] of Object.entries(tabs)) { - if (tab.changelog != null) { + const changelogFolder = docsYml.getChangelogFolderFromTabConfig(tab); + if (changelogFolder != null) { // For a tab-level `changelog` field, the tab IS the changelog // node — its slug/displayName define the leaf URL segment, so // we don't add tabSegments to `ancestorSegments` (that would // double-count). `getEffectiveChangelogSlugSegments` derives // them from `slug`/`title` on the location itself. out.push({ - where: `${breadcrumb} > tab "${tabId}" (changelog: ${tab.changelog})`, + where: `${breadcrumb} > tab "${tabId}" (changelog: ${changelogFolder})`, slug: tab.slug, - title: tab.displayName, + title: + tab.displayName ?? + (tab.blog != null && tab.changelog == null + ? docsYml.DEFAULT_BLOG_TITLE + : docsYml.DEFAULT_CHANGELOG_TITLE), ancestorSegments }); } @@ -210,7 +223,7 @@ function violationsForLocations(locations: ChangelogLocation[]): RuleViolation[] } const allowed = CHANGELOG_FEED_ALLOWED_SLUGS.join(", "); const sourceField = - loc.slug != null ? `slug: "${loc.slug}"` : `title: "${loc.title ?? DEFAULT_CHANGELOG_TITLE}"`; + loc.slug != null ? `slug: "${loc.slug}"` : `title: "${loc.title ?? docsYml.DEFAULT_CHANGELOG_TITLE}"`; const fullPath = "/" + allSegments.join("/"); violations.push({ severity: "error", @@ -269,10 +282,6 @@ export const ValidChangelogSlugRule: Rule = { } }; -function isChangelog(item: docsYml.RawSchemas.NavigationItem): item is docsYml.RawSchemas.ChangelogConfiguration { - return (item as docsYml.RawSchemas.ChangelogConfiguration)?.changelog != null; -} - function isSection(item: docsYml.RawSchemas.NavigationItem): item is docsYml.RawSchemas.SectionConfiguration { return (item as docsYml.RawSchemas.SectionConfiguration)?.section != null; } diff --git a/product-yml.schema.json b/product-yml.schema.json index b203debe0eb2..8f485388a544 100644 --- a/product-yml.schema.json +++ b/product-yml.schema.json @@ -239,6 +239,16 @@ "type": "null" } ] + }, + "blog": { + "oneOf": [ + { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1723,6 +1733,89 @@ ], "additionalProperties": false }, + "docs.BlogConfiguration": { + "type": "object", + "properties": { + "viewers": { + "oneOf": [ + { + "$ref": "#/definitions/docs.Role" + }, + { + "type": "null" + } + ] + }, + "orphaned": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "When `orphaned` is set to `true`, the roles will not inherit from parents." + }, + "feature-flag": { + "oneOf": [ + { + "$ref": "#/definitions/docs.FeatureFlagConfiguration" + }, + { + "type": "null" + } + ] + }, + "blog": { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "blog" + ], + "additionalProperties": false + }, "docs.TitleSource": { "type": "string", "enum": [ @@ -1899,6 +1992,9 @@ { "$ref": "#/definitions/docs.ChangelogConfiguration" }, + { + "$ref": "#/definitions/docs.BlogConfiguration" + }, { "$ref": "#/definitions/docs.FolderConfiguration" } diff --git a/version-yml.schema.json b/version-yml.schema.json index b203debe0eb2..8f485388a544 100644 --- a/version-yml.schema.json +++ b/version-yml.schema.json @@ -239,6 +239,16 @@ "type": "null" } ] + }, + "blog": { + "oneOf": [ + { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1723,6 +1733,89 @@ ], "additionalProperties": false }, + "docs.BlogConfiguration": { + "type": "object", + "properties": { + "viewers": { + "oneOf": [ + { + "$ref": "#/definitions/docs.Role" + }, + { + "type": "null" + } + ] + }, + "orphaned": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "When `orphaned` is set to `true`, the roles will not inherit from parents." + }, + "feature-flag": { + "oneOf": [ + { + "$ref": "#/definitions/docs.FeatureFlagConfiguration" + }, + { + "type": "null" + } + ] + }, + "blog": { + "$ref": "#/definitions/docs.ChangelogFolderRelativePath" + }, + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "blog" + ], + "additionalProperties": false + }, "docs.TitleSource": { "type": "string", "enum": [ @@ -1899,6 +1992,9 @@ { "$ref": "#/definitions/docs.ChangelogConfiguration" }, + { + "$ref": "#/definitions/docs.BlogConfiguration" + }, { "$ref": "#/definitions/docs.FolderConfiguration" }