Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
96 changes: 96 additions & 0 deletions docs-yml.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,16 @@
"type": "null"
}
]
},
"blog": {
"oneOf": [
{
"$ref": "#/definitions/docs.ChangelogFolderRelativePath"
},
{
"type": "null"
}
]
}
},
"required": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -3130,6 +3223,9 @@
{
"$ref": "#/definitions/docs.ChangelogConfiguration"
},
{
"$ref": "#/definitions/docs.BlogConfiguration"
},
{
"$ref": "#/definitions/docs.FolderConfiguration"
}
Expand Down
12 changes: 12 additions & 0 deletions fern/apis/docs-yml/definition/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ types:
Tabs with `href` must not have children in the navigation config.
target: optional<Target>
changelog: optional<ChangelogFolderRelativePath>
blog: optional<ChangelogFolderRelativePath>

ChangelogFolderRelativePath:
type: string
Expand Down Expand Up @@ -816,6 +817,7 @@ types:
- LibraryReferenceConfiguration
- LinkConfiguration
- ChangelogConfiguration
- BlogConfiguration
- FolderConfiguration

LogoConfiguration:
Expand Down Expand Up @@ -1411,6 +1413,16 @@ types:
hidden: optional<boolean>
# skip-slug: optional<boolean> # skip-slug is not needed for changelog

BlogConfiguration:
extends: [WithPermissions, WithFeatureFlags]
properties:
blog: ChangelogFolderRelativePath
title: optional<string> # defaults to "Blog"
slug: optional<string>
icon: optional<string>
hidden: optional<boolean>
# skip-slug: optional<boolean> # skip-slug is not needed for blog

SectionConfiguration:
extends: [WithPermissions, WithFeatureFlags]
properties:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<docsYml.ParsedDocsConfiguration> {
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"
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -1409,7 +1410,7 @@ async function expandFolderConfiguration({
}

async function convertNavigationItem({
rawConfig,
rawConfig: rawConfigInput,
absolutePathToFernFolder,
absolutePathToConfig,
context,
Expand All @@ -1421,6 +1422,8 @@ async function convertNavigationItem({
context: TaskContext;
folderTitleSource?: docsYml.RawSchemas.TitleSource;
}): Promise<docsYml.DocsNavigationItem> {
const rawConfig = normalizeNavigationItem(rawConfigInput);

if (isRawPageConfig(rawConfig)) {
return parsePageConfig(rawConfig, absolutePathToConfig);
}
Expand Down Expand Up @@ -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<docsYml.RawSchemas.NavigationItem, docsYml.RawSchemas.BlogConfiguration> {
if (!isRawBlogConfig(rawConfig)) {
return rawConfig;
}

const { blog, ...rest } = rawConfig;
return {
...rest,
changelog: blog,
title: rawConfig.title ?? "Blog"
};
Comment on lines +1759 to +1763

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.

🔴 Blog entries silently get an unservable feed address and the checker never warns

A blog navigation entry with no explicit title or slug is given the name "Blog" (title: rawConfig.title ?? "Blog" at packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts:1762) instead of the changelog default, so it lands on a web address whose RSS/Atom/JSON feed is not served and the pre-publish check still reports no problem.
Impact: Users who write the recommended - blog: ./blog get a page whose feed URLs return 404, with no warning from fern check.

Mismatch between the parsed default title and the raw-config assumption in the feed-slug rule

At build time the normalized item becomes { changelog: "./blog", title: "Blog" }. DocsDefinitionResolver.toChangelogNode passes item.title into ChangelogNodeConverter.toChangelogNode, which computes urlSlug: opts.slug ?? kebabCase(title) (packages/cli/docs-resolver/src/ChangelogNodeConverter.ts:74). With title === "Blog" the slug is blog, which is not in CHANGELOG_FEED_ALLOWED_SLUGS (packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/valid-changelog-slug.ts:19-26), so the docs middleware will not rewrite blog.rss / blog.atom / blog.json to the changelog handler.

Meanwhile the validator walks the raw docs.yml: collectChangelogLocations records title: item.title (undefined for - blog: ./blog) and getEffectiveChangelogSlugSegments falls back to DEFAULT_CHANGELOG_TITLE = "Changelog" → segment changelog, which is allowlisted. So violationsForLocations emits nothing even though the real URL is /blog.

The rule needs to apply the same Blog default that normalizeNavigationItem applies (e.g. pass title: item.title ?? (isBlogItem ? "Blog" : undefined) into the location), or blog must be added to the allowlist. Note this also contradicts the PR's stated "same slugs, same rss/atom/json feeds" behavior.

Prompt for agents
A blog navigation item declared as `- blog: ./blog` (no title, no slug) is normalized in normalizeNavigationItem (packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts) to `{ changelog: "./blog", title: "Blog" }`. Downstream, ChangelogNodeConverter.toChangelogNode computes the node's URL segment as `slug ?? kebabCase(title)`, so the resulting URL segment is `blog`. `blog` is not in CHANGELOG_FEED_ALLOWED_SLUGS in packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/valid-changelog-slug.ts, so the docs server will not serve the .rss/.atom/.json feed for that path.

At the same time, the valid-changelog-slug rule walks the raw docs.yml, where blog items have no `title`, and getEffectiveChangelogSlugSegments falls back to DEFAULT_CHANGELOG_TITLE = "Changelog" -> segment `changelog`, which IS allowlisted. Result: `fern check` reports no violation while the produced feed is unservable.

Two possible directions: (1) teach collectChangelogLocations/collectFromTabs to apply the same "Blog" default title that the parser applies when the item is a blog alias, so the rule computes `blog` and correctly flags it; and/or (2) add `blog`/`blogs` to CHANGELOG_FEED_ALLOWED_SLUGS if the docs middleware in fern-platform actually supports them (the comment in that file says the list must be kept in sync with `packages/commons/docs-server/src/patterns.ts` in fern-api/fern-platform). Whichever is chosen, the parser default title and the validator's assumed default title must agree.
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.

Confirmed and fixing. The default-title mismatch is real: the parser defaults an untitled blog item to Blog, so ChangelogNodeConverter derives the segment blog, while the rule reads the raw config (no title) and falls back to Changelog → validates a path the site never serves. Fixing the rule to apply the same Blog default for blog aliases, plus test cases for the no-title/no-slug form in both the top-level and tab shapes — the existing test passed slug: "changelog" explicitly, which is what hid it.

Not adding blog/blogs/posts to CHANGELOG_FEED_ALLOWED_SLUGS here: #17462 already does that (in sync with patterns.ts in fern-platform). Until it lands, fern check correctly flags an untitled - blog: because the feed genuinely isn't served; after it lands, the case passes cleanly.

}

function isRawFolderConfig(item: unknown): item is docsYml.RawSchemas.FolderConfiguration {
return isPlainObject(item) && typeof item.folder === "string";
}
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -820,6 +830,7 @@ export const NavigationItem: z.ZodType<unknown> = z.lazy(() =>
LibraryReferenceConfiguration,
LinkConfiguration,
ChangelogConfiguration,
BlogConfiguration,
FolderConfiguration
])
);
Expand Down Expand Up @@ -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()
})
);

Expand Down
1 change: 1 addition & 0 deletions packages/cli/configuration/src/docs-yml/index.ts
Original file line number Diff line number Diff line change
@@ -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";
19 changes: 19 additions & 0 deletions packages/cli/configuration/src/docs-yml/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as RawSchemas from "./schemas/index.js";

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;
}
Loading
Loading